Browse Source

modes concept

Dax Raad 7 months ago
parent
commit
a826936702

+ 19 - 0
packages/opencode/src/config/config.ts

@@ -55,6 +55,17 @@ export namespace Config {
   export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
   export type Mcp = z.infer<typeof Mcp>
 
+  export const Mode = z
+    .object({
+      model: z.string().optional(),
+      prompt: z.string().optional(),
+      tools: z.record(z.string(), z.boolean()).optional(),
+    })
+    .openapi({
+      ref: "ModeConfig",
+    })
+  export type Mode = z.infer<typeof Mode>
+
   export const Keybinds = z
     .object({
       leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
@@ -99,6 +110,7 @@ export namespace Config {
     .openapi({
       ref: "KeybindsConfig",
     })
+
   export const Info = z
     .object({
       $schema: z.string().optional().describe("JSON schema reference for configuration validation"),
@@ -108,6 +120,13 @@ export namespace Config {
       autoupdate: z.boolean().optional().describe("Automatically update to the latest version"),
       disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
       model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
+      mode: z
+        .object({
+          build: Mode.optional(),
+          plan: Mode.optional(),
+        })
+        .catchall(Mode)
+        .optional(),
       log_level: Log.Level.optional().describe("Minimum log level to write to log files"),
       provider: z
         .record(

+ 21 - 0
packages/opencode/src/server/server.ts

@@ -16,6 +16,7 @@ import { Config } from "../config/config"
 import { File } from "../file"
 import { LSP } from "../lsp"
 import { MessageV2 } from "../session/message-v2"
+import { Mode } from "../session/mode"
 
 const ERRORS = {
   400: {
@@ -681,6 +682,26 @@ export namespace Server {
           return c.json(true)
         },
       )
+      .get(
+        "/mode",
+        describeRoute({
+          description: "List all modes",
+          responses: {
+            200: {
+              description: "List of modes",
+              content: {
+                "application/json": {
+                  schema: resolver(Mode.Info.array()),
+                },
+              },
+            },
+          },
+        }),
+        async (c) => {
+          const modes = await Mode.list()
+          return c.json(modes)
+        },
+      )
 
     return result
   }

+ 22 - 5
packages/opencode/src/session/index.ts

@@ -15,6 +15,7 @@ import {
 } from "ai"
 
 import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
+import PROMPT_PLAN from "../session/prompt/plan.txt"
 
 import { App } from "../app/app"
 import { Bus } from "../bus"
@@ -29,12 +30,12 @@ import type { ModelsDev } from "../provider/models"
 import { Share } from "../share/share"
 import { Snapshot } from "../snapshot"
 import { Storage } from "../storage/storage"
-import type { Tool } from "../tool/tool"
 import { Log } from "../util/log"
 import { NamedError } from "../util/error"
 import { SystemPrompt } from "./system"
 import { FileTime } from "../file/time"
 import { MessageV2 } from "./message-v2"
+import { Mode } from "./mode"
 
 export namespace Session {
   const log = Log.create({ service: "session" })
@@ -281,13 +282,13 @@ export namespace Session {
     sessionID: string
     providerID: string
     modelID: string
+    mode?: string
     parts: MessageV2.UserPart[]
-    system?: string[]
-    tools?: Tool.Info[]
   }) {
     using abort = lock(input.sessionID)
     const l = log.clone().tag("session", input.sessionID)
     l.info("chatting")
+
     const model = await Provider.getModel(input.providerID, input.modelID)
     let msgs = await messages(input.sessionID)
     const session = await get(input.sessionID)
@@ -364,6 +365,7 @@ export namespace Session {
                 return [
                   {
                     type: "text",
+                    synthetic: true,
                     text: ["Called the Read tool on " + url.pathname, "<results>", text, "</results>"].join("\n"),
                   },
                 ]
@@ -373,6 +375,7 @@ export namespace Session {
                 {
                   type: "text",
                   text: `Called the Read tool with the following input: {\"filePath\":\"${url.pathname}\"}`,
+                  synthetic: true,
                 },
                 {
                   type: "file",
@@ -386,6 +389,14 @@ export namespace Session {
         return [part]
       }),
     ).then((x) => x.flat())
+
+    if (true)
+      input.parts.push({
+        type: "text",
+        text: PROMPT_PLAN,
+        synthetic: true,
+      })
+
     if (msgs.length === 0 && !session.parentID) {
       generateText({
         maxOutputTokens: input.providerID === "google" ? 1024 : 20,
@@ -431,9 +442,13 @@ export namespace Session {
     await updateMessage(msg)
     msgs.push(msg)
 
-    const system = input.system ?? SystemPrompt.provider(input.providerID, input.modelID)
+    const mode = await Mode.get(input.mode ?? "build")
+    let system = mode.prompt ? [mode.prompt] : SystemPrompt.provider(input.providerID, input.modelID)
     system.push(...(await SystemPrompt.environment()))
     system.push(...(await SystemPrompt.custom()))
+    // max 2 system prompt messages for caching purposes
+    const [first, ...rest] = system
+    system = [first, rest.join("\n")]
 
     const next: MessageV2.Info = {
       id: Identifier.ascending("message"),
@@ -462,7 +477,8 @@ export namespace Session {
     const tools: Record<string, AITool> = {}
 
     for (const item of await Provider.tools(input.providerID)) {
-      tools[item.id.replaceAll(".", "_")] = tool({
+      if (mode.tools[item.id] === false) continue
+      tools[item.id] = tool({
         id: item.id as any,
         description: item.description,
         inputSchema: item.parameters as ZodSchema,
@@ -494,6 +510,7 @@ export namespace Session {
     }
 
     for (const [key, item] of Object.entries(await MCP.tools())) {
+      if (mode.tools[key] === false) continue
       const execute = item.execute
       if (!execute) continue
       item.execute = async (args, opts) => {

+ 1 - 0
packages/opencode/src/session/message-v2.ts

@@ -76,6 +76,7 @@ export namespace MessageV2 {
     .object({
       type: z.literal("text"),
       text: z.string(),
+      synthetic: z.boolean().optional(),
     })
     .openapi({
       ref: "TextPart",

+ 74 - 0
packages/opencode/src/session/mode.ts

@@ -0,0 +1,74 @@
+import { mergeDeep } from "remeda"
+import { App } from "../app/app"
+import { Config } from "../config/config"
+import z from "zod"
+
+export namespace Mode {
+  export const Info = z
+    .object({
+      name: z.string(),
+      model: z
+        .object({
+          modelID: z.string(),
+          providerID: z.string(),
+        })
+        .optional(),
+      prompt: z.string().optional(),
+      tools: z
+        .object({
+          write: z.boolean().optional(),
+          edit: z.boolean().optional(),
+          patch: z.boolean().optional(),
+        })
+        .optional(),
+    })
+    .openapi({
+      ref: "Mode",
+    })
+  export type Info = z.infer<typeof Info>
+  const state = App.state("mode", async () => {
+    const cfg = await Config.get()
+    const mode = mergeDeep(
+      {
+        build: {},
+        plan: {
+          tools: {
+            write: false,
+            edit: false,
+            patch: false,
+          },
+        },
+      },
+      cfg.mode ?? {},
+    )
+    const result: Record<string, Info> = {}
+    for (const [key, value] of Object.entries(mode)) {
+      let item = result[key]
+      if (!item)
+        item = result[key] = {
+          name: key,
+          tools: {},
+        }
+      const model = value.model ?? cfg.model
+      if (model) {
+        const [providerID, ...rest] = model.split("/")
+        const modelID = rest.join("/")
+        item.model = {
+          modelID,
+          providerID,
+        }
+      }
+      if (value.prompt) item.prompt = await Bun.file(value.prompt).text()
+      if (value.tools) item.tools = value.tools
+    }
+    return result
+  })
+
+  export async function get(mode: string) {
+    return state().then((x) => x[mode])
+  }
+
+  export async function list() {
+    return state().then((x) => Object.values(x))
+  }
+}

+ 3 - 0
packages/opencode/src/session/prompt/plan.txt

@@ -0,0 +1,3 @@
+<system-reminder>
+Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). 
+</system-reminder>