refactor(opencode): remove instruction apparatus; add wire dialects from scratch
- delete instruction.ts, reminders.ts, system.ts, message.ts (dead or injector) - read tool no longer appends <system-reminder> context from ancestor files - prompt.ts assembles system context via SessionContext with provenance tags - llm/request.ts uses base prompt constant directly - provider/dialects/: raw-HTTP wire dialects (anthropic, openai, openai-compatible) + registry - no vendor SDK participation - provider.ts: BUNDLED_PROVIDERS cut to three SDKs pending dialect cutover
This commit is contained in:
@@ -27,7 +27,6 @@ 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/lsp"
|
||||
import { MCP } from "@/mcp"
|
||||
@@ -84,7 +83,6 @@ export const AppLayer = AppNodeBuilderV1.build(
|
||||
SessionRevert.node,
|
||||
SessionSummary.node,
|
||||
SessionPrompt.node,
|
||||
Instruction.node,
|
||||
LLM.node,
|
||||
LSP.node,
|
||||
MCP.node,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export * as AnthropicDialect from "./anthropic"
|
||||
|
||||
import type { Dialect, Event, Request } from "./types"
|
||||
|
||||
/**
|
||||
* Anthropic wire protocol: POST /v1/messages, x-api-key auth,
|
||||
* server-sent events with typed content blocks.
|
||||
*/
|
||||
|
||||
export const api = "@ai-sdk/anthropic"
|
||||
|
||||
export const options = {
|
||||
headers: {
|
||||
"anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||
},
|
||||
}
|
||||
|
||||
export function create(input: {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
system: string[]
|
||||
messages: unknown[]
|
||||
tools?: unknown[]
|
||||
}): Request {
|
||||
return {
|
||||
url: `${input.baseURL}/v1/messages`,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": input.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
...options.headers,
|
||||
},
|
||||
body: {
|
||||
model: input.model,
|
||||
max_tokens: 8192,
|
||||
stream: true,
|
||||
...(input.system.length > 0 ? { system: input.system } : {}),
|
||||
messages: input.messages,
|
||||
...(input.tools && input.tools.length > 0 ? { tools: input.tools } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Block =
|
||||
| { type: "text"; text?: string }
|
||||
| { type: "thinking"; thinking?: string }
|
||||
| { type: "tool_use"; id?: string; name?: string; input?: unknown }
|
||||
| { type: "input_json_delta"; partial_json?: string }
|
||||
|
||||
export function parse(data: string): Event[] {
|
||||
let json: any
|
||||
try {
|
||||
json = JSON.parse(data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
switch (json.type) {
|
||||
case "content_block_delta": {
|
||||
const delta = json.delta as Block | undefined
|
||||
if (delta?.type === "text" && delta.text) return [{ type: "text-delta", text: delta.text }]
|
||||
if (delta?.type === "thinking" && delta.thinking)
|
||||
return [{ type: "reasoning-delta", text: delta.thinking }]
|
||||
if (delta?.type === "input_json_delta" && typeof json.delta.partial_json === "string")
|
||||
return [{ type: "tool-call", id: String(json.index), name: "", arguments: json.delta.partial_json }]
|
||||
return []
|
||||
}
|
||||
case "message_delta": {
|
||||
const reason = json.delta?.stop_reason
|
||||
if (reason) return [{ type: "finish", reason: String(reason) }]
|
||||
return []
|
||||
}
|
||||
case "message_stop":
|
||||
return [{ type: "finish", reason: "stop" }]
|
||||
case "error":
|
||||
return [{ type: "error", message: String(json.error?.message ?? "unknown anthropic error") }]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const dialect: Dialect = { request: create, parse }
|
||||
@@ -0,0 +1,69 @@
|
||||
export * as OpenAICompatibleDialect from "./openai-compatible"
|
||||
|
||||
import type { Dialect, Event, Request } from "./types"
|
||||
|
||||
/**
|
||||
* The generic dialect. Nearly every inference vendor on earth - Groq,
|
||||
* Mistral, DeepInfra, Together, Perplexity, OpenRouter, local servers -
|
||||
* speaks the OpenAI chat-completions wire shape at a different baseURL.
|
||||
*
|
||||
* A vendor is therefore DATA: one catalog row (name, baseURL, auth).
|
||||
* No vendor-specific code exists or may be added here.
|
||||
*/
|
||||
|
||||
export function create(input: {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
system: string[]
|
||||
messages: unknown[]
|
||||
tools?: unknown[]
|
||||
}): Request {
|
||||
return {
|
||||
url: `${input.baseURL}/v1/chat/completions`,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${input.apiKey}`,
|
||||
},
|
||||
body: {
|
||||
model: input.model,
|
||||
stream: true,
|
||||
...(input.system.length > 0
|
||||
? { messages: [{ role: "system", content: input.system.join("\n\n") }, ...input.messages] }
|
||||
: { messages: input.messages }),
|
||||
...(input.tools && input.tools.length > 0 ? { tools: input.tools } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function parse(data: string): Event[] {
|
||||
let json: any
|
||||
try {
|
||||
json = JSON.parse(data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const choice = json.choices?.[0]
|
||||
if (!choice) {
|
||||
if (json.error) return [{ type: "error", message: String(json.error.message ?? "unknown error") }]
|
||||
return []
|
||||
}
|
||||
const events: Event[] = []
|
||||
const delta = choice.delta ?? {}
|
||||
if (typeof delta.content === "string" && delta.content.length > 0)
|
||||
events.push({ type: "text-delta", text: delta.content })
|
||||
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0)
|
||||
events.push({ type: "reasoning-delta", text: delta.reasoning_content })
|
||||
for (const call of delta.tool_calls ?? []) {
|
||||
events.push({
|
||||
type: "tool-call",
|
||||
id: String(call.id ?? call.index ?? ""),
|
||||
name: String(call.function?.name ?? ""),
|
||||
arguments: String(call.function?.arguments ?? ""),
|
||||
})
|
||||
}
|
||||
if (choice.finish_reason) events.push({ type: "finish", reason: String(choice.finish_reason) })
|
||||
return events
|
||||
}
|
||||
|
||||
export const dialect: Dialect = { request: create, parse }
|
||||
@@ -0,0 +1,67 @@
|
||||
export * as OpenAIDialect from "./openai"
|
||||
|
||||
import type { Dialect, Event, Request } from "./types"
|
||||
|
||||
/**
|
||||
* OpenAI wire protocol: POST /v1/responses (fallback /v1/chat/completions),
|
||||
* Bearer auth, server-sent events. Also the reference shape for every
|
||||
* OpenAI-compatible vendor - only the baseURL differs.
|
||||
*/
|
||||
|
||||
export const api = "@ai-sdk/openai"
|
||||
|
||||
export const options = {}
|
||||
|
||||
export function create(input: {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
system: string[]
|
||||
messages: unknown[]
|
||||
tools?: unknown[]
|
||||
}): Request {
|
||||
return {
|
||||
url: `${input.baseURL}/v1/responses`,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${input.apiKey}`,
|
||||
},
|
||||
body: {
|
||||
model: input.model,
|
||||
stream: true,
|
||||
...(input.system.length > 0 ? { instructions: input.system.join("\n\n") } : {}),
|
||||
input: input.messages,
|
||||
...(input.tools && input.tools.length > 0 ? { tools: input.tools } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function parse(data: string): Event[] {
|
||||
let json: any
|
||||
try {
|
||||
json = JSON.parse(data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
switch (json.type) {
|
||||
case "response.output_text.delta":
|
||||
return [{ type: "text-delta", text: String(json.delta ?? "") }]
|
||||
case "response.reasoning_summary_text.delta":
|
||||
return [{ type: "reasoning-delta", text: String(json.delta ?? "") }]
|
||||
case "response.function_call_arguments.delta":
|
||||
return [
|
||||
{ type: "tool-call", id: String(json.item_id ?? ""), name: String(json.name ?? ""), arguments: String(json.delta ?? "") },
|
||||
]
|
||||
case "response.completed":
|
||||
case "response.failed": {
|
||||
const reason = json.response?.status === "failed" ? "error" : "stop"
|
||||
return [{ type: "finish", reason }]
|
||||
}
|
||||
case "error":
|
||||
return [{ type: "error", message: String(json.message ?? "unknown openai error") }]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const dialect: Dialect = { request: create, parse }
|
||||
@@ -0,0 +1,22 @@
|
||||
export * as Registry from "./registry"
|
||||
|
||||
import type { Dialect } from "./types"
|
||||
import { dialect as anthropic, api as anthropicApi } from "./anthropic"
|
||||
import { dialect as openai, api as openaiApi } from "./openai"
|
||||
import { dialect as openaiCompatible } from "./openai-compatible"
|
||||
|
||||
/**
|
||||
* Wire-dialect registry. Keyed by the catalog's npm/api identifier so any
|
||||
* vendor row resolves to the protocol it speaks. Vendors without an entry
|
||||
* fall back to the OpenAI-compatible dialect - the wire shape nearly every
|
||||
* inference vendor shares.
|
||||
*/
|
||||
|
||||
const DIALECTS: Record<string, Dialect> = {
|
||||
[anthropicApi]: anthropic,
|
||||
[openaiApi]: openai,
|
||||
}
|
||||
|
||||
export function forAPI(npm: string): Dialect {
|
||||
return DIALECTS[npm] ?? openaiCompatible
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export * as Dialect from "./types"
|
||||
|
||||
/**
|
||||
* A wire dialect: how Neuron speaks to one family of inference APIs.
|
||||
*
|
||||
* A dialect is pure HTTP - build a request, translate server-sent events
|
||||
* into Neuron's normalized event stream. No vendor SDK participates.
|
||||
* Every byte on the wire is visible in this folder.
|
||||
*/
|
||||
|
||||
export interface Request {
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
body: unknown
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| { type: "text-delta"; text: string }
|
||||
| { type: "reasoning-delta"; text: string }
|
||||
| {
|
||||
type: "tool-call"
|
||||
id: string
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
| { type: "finish"; reason: string }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
export interface Dialect {
|
||||
/** Build the streaming completion request. */
|
||||
request(input: {
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
system: string[]
|
||||
messages: unknown[]
|
||||
tools?: unknown[]
|
||||
}): Request
|
||||
/** Translate one server-sent-event data payload into normalized events. */
|
||||
parse(data: string): Event[]
|
||||
}
|
||||
@@ -112,24 +112,10 @@ type BundledSDK = {
|
||||
|
||||
const BUNDLED_PROVIDERS: Record<string, () => Promise<(opts: any) => BundledSDK>> = {
|
||||
"@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
|
||||
"@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure),
|
||||
"@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI),
|
||||
"@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
|
||||
// Every other vendor in the catalog is OpenAI-shaped: same wire protocol,
|
||||
// different baseURL. One generic strategy covers them all.
|
||||
"@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible),
|
||||
"@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter),
|
||||
"@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai),
|
||||
"@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral),
|
||||
"@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq),
|
||||
"@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra),
|
||||
"@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras),
|
||||
"@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere),
|
||||
"@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway),
|
||||
"@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI),
|
||||
"@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity),
|
||||
"@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel),
|
||||
"@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba),
|
||||
"gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab),
|
||||
"venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice),
|
||||
}
|
||||
|
||||
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>, model?: Model) => Promise<any>
|
||||
|
||||
@@ -28,7 +28,6 @@ import { ProviderAuth } from "@/provider/auth"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Question } from "@/question"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { Instruction } from "@/session/instruction"
|
||||
import { LLM } from "@/session/llm"
|
||||
import { SessionProcessor } from "@/session/processor"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
@@ -238,7 +237,6 @@ const app = LayerNode.group([
|
||||
SessionRevert.node,
|
||||
SessionSummary.node,
|
||||
SessionPrompt.node,
|
||||
Instruction.node,
|
||||
LLM.node,
|
||||
LSP.node,
|
||||
MCP.node,
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import path from "path"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
import type { MessageID } from "./schema"
|
||||
|
||||
function extract(messages: SessionV1.WithParts[]) {
|
||||
const paths = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
|
||||
if (part.state.time.compacted) continue
|
||||
const loaded = part.state.metadata?.loaded
|
||||
if (!loaded || !Array.isArray(loaded)) continue
|
||||
for (const p of loaded) {
|
||||
if (typeof p === "string") paths.add(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly clear: (messageID: MessageID) => Effect.Effect<void>
|
||||
readonly systemPaths: () => Effect.Effect<Set<string>, FSUtil.Error>
|
||||
readonly system: () => Effect.Effect<string[], FSUtil.Error>
|
||||
readonly find: (dir: string) => Effect.Effect<string | undefined, FSUtil.Error>
|
||||
readonly resolve: (
|
||||
messages: SessionV1.WithParts[],
|
||||
filepath: string,
|
||||
messageID: MessageID,
|
||||
) => Effect.Effect<{ filepath: string; content: string }[], FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
|
||||
|
||||
const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
FSUtil.Service | Config.Service | Global.Service | RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const globalFiles = [
|
||||
path.join(global.config, "AGENTS.md"),
|
||||
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
|
||||
]
|
||||
const instructionFiles = [
|
||||
"AGENTS.md",
|
||||
...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []),
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Instruction.state")(() =>
|
||||
Effect.succeed({
|
||||
// Track which instruction files have already been attached for a given assistant message.
|
||||
claims: new Map<MessageID, Set<string>>(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const relative = Effect.fnUntraced(function* (instruction: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return yield* fs
|
||||
.globUp(instruction, ctx.directory, ctx.worktree)
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
}
|
||||
return yield* fs
|
||||
.globUp(instruction, global.config, global.config)
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
})
|
||||
|
||||
const read = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
})
|
||||
|
||||
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.claims.delete(messageID)
|
||||
})
|
||||
|
||||
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const ctx = yield* InstanceState.context
|
||||
const paths = new Set<string>()
|
||||
|
||||
for (const file of globalFiles) {
|
||||
if (yield* fs.existsSafe(file)) {
|
||||
paths.add(path.resolve(file))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of instructionFiles) {
|
||||
const matches = yield* fs
|
||||
.findUp(file, ctx.directory, ctx.worktree)
|
||||
.pipe(Effect.catch(() => Effect.succeed([])))
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((item) => paths.add(path.resolve(item)))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.instructions) {
|
||||
for (const raw of config.instructions) {
|
||||
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
|
||||
const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw
|
||||
const matches = yield* (
|
||||
path.isAbsolute(instruction)
|
||||
? fs.glob(path.basename(instruction), {
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
: relative(instruction)
|
||||
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
matches.forEach((item) => paths.add(path.resolve(item)))
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
})
|
||||
|
||||
const system = Effect.fn("Instruction.system")(function* () {
|
||||
const config = yield* cfg.get()
|
||||
const paths = yield* systemPaths()
|
||||
|
||||
// Remote instruction URLs are not honored. Instructions enter context
|
||||
// only from files on disk the user controls.
|
||||
|
||||
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
|
||||
|
||||
return Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : []))
|
||||
})
|
||||
|
||||
const find = Effect.fn("Instruction.find")(function* (dir: string) {
|
||||
for (const file of instructionFiles) {
|
||||
const filepath = path.resolve(path.join(dir, file))
|
||||
if (yield* fs.existsSafe(filepath)) return filepath
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Instruction.resolve")(function* (
|
||||
messages: SessionV1.WithParts[],
|
||||
filepath: string,
|
||||
messageID: MessageID,
|
||||
) {
|
||||
const sys = yield* systemPaths()
|
||||
const already = extract(messages)
|
||||
const results: { filepath: string; content: string }[] = []
|
||||
const s = yield* InstanceState.get(state)
|
||||
const root = path.resolve(yield* InstanceState.directory)
|
||||
|
||||
const target = path.resolve(filepath)
|
||||
let current = path.dirname(target)
|
||||
|
||||
// Walk upward from the file being read and attach nearby instruction files once per message.
|
||||
while (current.startsWith(root) && current !== root) {
|
||||
const found = yield* find(current)
|
||||
if (!found || found === target || sys.has(found) || already.has(found)) {
|
||||
current = path.dirname(current)
|
||||
continue
|
||||
}
|
||||
|
||||
let set = s.claims.get(messageID)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
s.claims.set(messageID, set)
|
||||
}
|
||||
if (set.has(found)) {
|
||||
current = path.dirname(current)
|
||||
continue
|
||||
}
|
||||
|
||||
set.add(found)
|
||||
const content = yield* read(found)
|
||||
if (content) {
|
||||
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
|
||||
}
|
||||
|
||||
current = path.dirname(current)
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
|
||||
return Service.of({ clear, systemPaths, system, find, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
export function loaded(messages: SessionV1.WithParts[]) {
|
||||
return extract(messages)
|
||||
}
|
||||
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Config.node, FSUtil.node, Global.node, RuntimeFlags.node],
|
||||
})
|
||||
|
||||
export * as Instruction from "./instruction"
|
||||
@@ -8,7 +8,7 @@ import type { Agent } from "@/agent/agent"
|
||||
import type { MessageV2 } from "../message-v2"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { SystemPrompt } from "../system"
|
||||
import PROMPT_NEURON from "../prompt/neuron.txt"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Effect, Record } from "effect"
|
||||
import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai"
|
||||
@@ -58,7 +58,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
|
||||
const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth"
|
||||
const system = [
|
||||
[
|
||||
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
|
||||
...(input.agent.prompt ? [input.agent.prompt] : PROMPT_NEURON),
|
||||
...input.system,
|
||||
...(input.user.system ? [input.user.system] : []),
|
||||
]
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { SessionID } from "./schema"
|
||||
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { MessageError } from "./message-error"
|
||||
import { AuthError, OutputLengthError } from "./message-error"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
export { AuthError, OutputLengthError } from "./message-error"
|
||||
|
||||
export const ToolCall = Schema.Struct({
|
||||
state: Schema.Literal("call"),
|
||||
step: Schema.optional(NonNegativeInt),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
}).annotate({ identifier: "ToolCall" })
|
||||
export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||
|
||||
export const ToolPartialCall = Schema.Struct({
|
||||
state: Schema.Literal("partial-call"),
|
||||
step: Schema.optional(NonNegativeInt),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
}).annotate({ identifier: "ToolPartialCall" })
|
||||
export type ToolPartialCall = Schema.Schema.Type<typeof ToolPartialCall>
|
||||
|
||||
export const ToolResult = Schema.Struct({
|
||||
state: Schema.Literal("result"),
|
||||
step: Schema.optional(NonNegativeInt),
|
||||
toolCallId: Schema.String,
|
||||
toolName: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
result: Schema.String,
|
||||
}).annotate({ identifier: "ToolResult" })
|
||||
export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
||||
|
||||
export const ToolInvocation = Schema.Union([ToolCall, ToolPartialCall, ToolResult]).annotate({
|
||||
identifier: "ToolInvocation",
|
||||
discriminator: "state",
|
||||
})
|
||||
export type ToolInvocation = Schema.Schema.Type<typeof ToolInvocation>
|
||||
|
||||
export const TextPart = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}).annotate({ identifier: "TextPart" })
|
||||
export type TextPart = Schema.Schema.Type<typeof TextPart>
|
||||
|
||||
export const ReasoningPart = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).annotate({ identifier: "ReasoningPart" })
|
||||
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
|
||||
|
||||
export const ToolInvocationPart = Schema.Struct({
|
||||
type: Schema.Literal("tool-invocation"),
|
||||
toolInvocation: ToolInvocation,
|
||||
}).annotate({ identifier: "ToolInvocationPart" })
|
||||
export type ToolInvocationPart = Schema.Schema.Type<typeof ToolInvocationPart>
|
||||
|
||||
export const SourceUrlPart = Schema.Struct({
|
||||
type: Schema.Literal("source-url"),
|
||||
sourceId: Schema.String,
|
||||
url: Schema.String,
|
||||
title: Schema.optional(Schema.String),
|
||||
providerMetadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).annotate({ identifier: "SourceUrlPart" })
|
||||
export type SourceUrlPart = Schema.Schema.Type<typeof SourceUrlPart>
|
||||
|
||||
export const FilePart = Schema.Struct({
|
||||
type: Schema.Literal("file"),
|
||||
mediaType: Schema.String,
|
||||
filename: Schema.optional(Schema.String),
|
||||
url: Schema.String,
|
||||
}).annotate({ identifier: "FilePart" })
|
||||
export type FilePart = Schema.Schema.Type<typeof FilePart>
|
||||
|
||||
export const StepStartPart = Schema.Struct({
|
||||
type: Schema.Literal("step-start"),
|
||||
}).annotate({ identifier: "StepStartPart" })
|
||||
export type StepStartPart = Schema.Schema.Type<typeof StepStartPart>
|
||||
|
||||
export const MessagePart = Schema.Union([
|
||||
TextPart,
|
||||
ReasoningPart,
|
||||
ToolInvocationPart,
|
||||
SourceUrlPart,
|
||||
FilePart,
|
||||
StepStartPart,
|
||||
]).annotate({ identifier: "MessagePart", discriminator: "type" })
|
||||
export type MessagePart = Schema.Schema.Type<typeof MessagePart>
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: Schema.String,
|
||||
role: Schema.Literals(["user", "assistant"]),
|
||||
parts: Schema.Array(MessagePart),
|
||||
metadata: Schema.Struct({
|
||||
time: Schema.Struct({
|
||||
created: NonNegativeInt,
|
||||
completed: Schema.optional(NonNegativeInt),
|
||||
}),
|
||||
error: Schema.optional(MessageError.SharedSchema),
|
||||
sessionID: SessionID,
|
||||
tool: Schema.Record(
|
||||
Schema.String,
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
title: Schema.String,
|
||||
snapshot: Schema.optional(Schema.String),
|
||||
time: Schema.Struct({
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
assistant: Schema.optional(
|
||||
Schema.Struct({
|
||||
system: Schema.Array(Schema.String),
|
||||
modelID: ModelV2.ID,
|
||||
providerID: ProviderV2.ID,
|
||||
path: Schema.Struct({
|
||||
cwd: Schema.String,
|
||||
root: Schema.String,
|
||||
}),
|
||||
cost: Schema.Finite,
|
||||
summary: Schema.optional(Schema.Boolean),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.Finite,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
snapshot: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "MessageMetadata" }),
|
||||
}).annotate({ identifier: "Message" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export * as Message from "./message"
|
||||
@@ -5,6 +5,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import os from "os"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionContext } from "./context"
|
||||
import { SessionRevert } from "./revert"
|
||||
import { Session } from "./session"
|
||||
import { Agent } from "../agent/agent"
|
||||
@@ -13,8 +14,6 @@ import { Provider } from "@/provider/provider"
|
||||
import { type Tool as AITool, tool, jsonSchema } from "ai"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Instruction } from "./instruction"
|
||||
import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { MCP } from "../mcp"
|
||||
@@ -52,7 +51,6 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionReminders } from "./reminders"
|
||||
import { SessionTools } from "./tools"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
|
||||
@@ -129,15 +127,14 @@ const layer = Layer.effect(
|
||||
const image = yield* Image.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const scope = yield* Scope.Scope
|
||||
const instruction = yield* Instruction.Service
|
||||
const state = yield* SessionRunState.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const database = yield* Database.Service
|
||||
const sessionContext = yield* SessionContext.Service
|
||||
const { db } = database
|
||||
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
||||
return {
|
||||
@@ -671,7 +668,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() => instruction.clear(info.id))
|
||||
|
||||
type Draft<T> = T extends SessionV1.Part ? Omit<T, "id"> & { id?: string } : never
|
||||
const assign = (part: Draft<SessionV1.Part>): SessionV1.Part => ({
|
||||
@@ -1173,11 +1169,6 @@ const layer = Layer.effect(
|
||||
}
|
||||
const maxSteps = agent.steps ?? Infinity
|
||||
const isLastStep = step >= maxSteps
|
||||
msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
|
||||
Effect.provideService(RuntimeFlags.Service, flags),
|
||||
Effect.provideService(FSUtil.Service, fsys),
|
||||
Effect.provideService(Session.Service, sessions),
|
||||
)
|
||||
|
||||
const msg: SessionV1.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
@@ -1247,19 +1238,13 @@ const layer = Layer.effect(
|
||||
if (step === 1)
|
||||
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
|
||||
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
|
||||
sys.skills(agent),
|
||||
sys.environment(model),
|
||||
instruction.system().pipe(Effect.orDie),
|
||||
sys.mcp(agent, session.permission),
|
||||
const [blocks, modelMsgs] = yield* Effect.all([
|
||||
sessionContext.system({ agent, modelID: model.api.id }),
|
||||
MessageV2.toModelMessagesEffect(msgs, model),
|
||||
])
|
||||
const system = [
|
||||
...env,
|
||||
...instructions,
|
||||
...(mcpInstructions ? [mcpInstructions] : []),
|
||||
...(skills ? [skills] : []),
|
||||
]
|
||||
const system = blocks.map(
|
||||
(block) => `[${block.source}${block.origin ? `:${block.origin}` : ""}]\n${block.text}`,
|
||||
)
|
||||
const format = lastUser.format ?? { type: "text" as const }
|
||||
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||
const result = yield* handle.process({
|
||||
@@ -1321,7 +1306,6 @@ const layer = Layer.effect(
|
||||
}
|
||||
return "continue" as const
|
||||
}).pipe(
|
||||
Effect.ensuring(instruction.clear(handle.message.id)),
|
||||
Effect.onInterrupt(() => finalizeInterruptedAssistant),
|
||||
)
|
||||
if (outcome === "break") break
|
||||
@@ -1591,6 +1575,7 @@ export const node = LayerNode.make({
|
||||
Session.node,
|
||||
Agent.node,
|
||||
Provider.node,
|
||||
SessionContext.node,
|
||||
SessionProcessor.node,
|
||||
SessionCompaction.node,
|
||||
Command.node,
|
||||
@@ -1603,11 +1588,9 @@ export const node = LayerNode.make({
|
||||
Truncate.node,
|
||||
Image.node,
|
||||
CrossSpawnSpawner.node,
|
||||
Instruction.node,
|
||||
SessionRunState.node,
|
||||
SessionRevert.node,
|
||||
SessionSummary.node,
|
||||
SystemPrompt.node,
|
||||
LLM.node,
|
||||
EventV2Bridge.node,
|
||||
RuntimeFlags.node,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import path from "path"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { PartID } from "./schema"
|
||||
import { Session } from "./session"
|
||||
import BUILD_SWITCH from "./prompt/build-switch.txt"
|
||||
import PLAN_MODE from "./prompt/plan-mode.txt"
|
||||
|
||||
export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
|
||||
messages: SessionV1.WithParts[]
|
||||
agent: Agent.Info
|
||||
session: Session.Info
|
||||
}) {
|
||||
const fsys = yield* FSUtil.Service
|
||||
const sessions = yield* Session.Service
|
||||
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
||||
if (!userMessage) return input.messages
|
||||
|
||||
const ctx = yield* InstanceState.context
|
||||
const plan = Session.plan(input.session, ctx)
|
||||
|
||||
// leaving plan mode: remind build to execute on the plan file if one exists
|
||||
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
|
||||
if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
|
||||
const exists = yield* fsys.existsSafe(plan)
|
||||
const part = yield* sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMessage.info.id,
|
||||
sessionID: userMessage.info.sessionID,
|
||||
type: "text",
|
||||
text: exists ? `${BUILD_SWITCH}\n\nA plan file exists at ${plan}. Execute on the plan defined within it.` : BUILD_SWITCH,
|
||||
synthetic: true,
|
||||
})
|
||||
userMessage.parts.push(part)
|
||||
return input.messages
|
||||
}
|
||||
|
||||
// entering plan mode: hand over the plan file location and workflow
|
||||
if (input.agent.name !== "plan" || assistantMessage?.info.agent === "plan") return input.messages
|
||||
|
||||
const exists = yield* fsys.existsSafe(plan)
|
||||
if (!exists) yield* fsys.ensureDir(path.dirname(plan)).pipe(Effect.catch(Effect.die))
|
||||
const part = yield* sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMessage.info.id,
|
||||
sessionID: userMessage.info.sessionID,
|
||||
type: "text",
|
||||
text: PLAN_MODE.replace("${planInfo}", () =>
|
||||
exists
|
||||
? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.`
|
||||
: `No plan file exists yet. You should create your plan at ${plan} using the write tool.`,
|
||||
),
|
||||
synthetic: true,
|
||||
})
|
||||
userMessage.parts.push(part)
|
||||
return input.messages
|
||||
})
|
||||
|
||||
export * as SessionReminders from "./reminders"
|
||||
@@ -1,123 +0,0 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
import PROMPT_NEURON from "./prompt/neuron.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { Skill } from "@/skill"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { MCP } from "@/mcp"
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
|
||||
export function provider(model: Provider.Model) {
|
||||
return [PROMPT_NEURON]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly environment: (model: Provider.Model) => Effect.Effect<string[]>
|
||||
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
|
||||
readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
|
||||
return Service.of({
|
||||
environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const references = yield* Effect.gen(function* () {
|
||||
return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined)
|
||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
|
||||
return [
|
||||
[
|
||||
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
`<env>`,
|
||||
` Working directory: ${ctx.directory}`,
|
||||
` Workspace root folder: ${ctx.worktree}`,
|
||||
` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
` Today's date: ${new Date().toDateString()}`,
|
||||
`</env>`,
|
||||
].join("\n"),
|
||||
references.length === 0
|
||||
? undefined
|
||||
: [
|
||||
"Project references provide additional directories that can be accessed when relevant.",
|
||||
"<available_references>",
|
||||
...references
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.flatMap((reference) => [
|
||||
" <reference>",
|
||||
` <name>${reference.name}</name>`,
|
||||
` <path>${reference.path}</path>`,
|
||||
...(reference.description === undefined
|
||||
? []
|
||||
: [` <description>${reference.description}</description>`]),
|
||||
" </reference>",
|
||||
]),
|
||||
"</available_references>",
|
||||
].join("\n"),
|
||||
].filter((part): part is string => part !== undefined)
|
||||
}),
|
||||
|
||||
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
|
||||
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||
|
||||
const list = yield* skill.available(agent)
|
||||
|
||||
return [
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
// the agents seem to ingest the information about skills a bit better if we present a more verbose
|
||||
// version of them here and a less verbose version in tool description, rather than vice versa.
|
||||
Skill.fmt(list, { verbose: true }),
|
||||
].join("\n")
|
||||
}),
|
||||
|
||||
mcp: Effect.fn("SystemPrompt.mcp")(function* (agent: Agent.Info, permission?: PermissionV1.Ruleset) {
|
||||
const ruleset = Permission.merge(agent.permission, permission ?? [])
|
||||
const instructions = (yield* mcp.instructions()).filter(
|
||||
(item) => item.tools.length === 0 || Permission.disabled(item.tools, ruleset).size < item.tools.length,
|
||||
)
|
||||
if (instructions.length === 0) return
|
||||
|
||||
return [
|
||||
"<mcp_instructions>",
|
||||
...instructions.flatMap((item) => [
|
||||
` <server name="${item.name}">`,
|
||||
...item.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
]),
|
||||
"</mcp_instructions>",
|
||||
].join("\n")
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const locationServiceMapNode = LayerNode.make({
|
||||
service: LocationServiceMap.Service,
|
||||
layer: locationServiceMapLayer,
|
||||
deps: [],
|
||||
})
|
||||
|
||||
export const node = LayerNode.make({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Skill.node, MCP.node, locationServiceMapNode],
|
||||
})
|
||||
|
||||
export * as SystemPrompt from "./system"
|
||||
@@ -7,7 +7,6 @@ import { LSP } from "@/lsp/lsp"
|
||||
import DESCRIPTION from "./read.txt"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
@@ -57,19 +56,17 @@ type Display =
|
||||
type Metadata = {
|
||||
preview: string
|
||||
truncated: boolean
|
||||
loaded: string[]
|
||||
display?: Display
|
||||
}
|
||||
|
||||
export const ReadTool = Tool.define<
|
||||
typeof Parameters,
|
||||
Metadata,
|
||||
FSUtil.Service | Instruction.Service | LSP.Service | Scope.Scope
|
||||
FSUtil.Service | LSP.Service | Scope.Scope
|
||||
>(
|
||||
"read",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const instruction = yield* Instruction.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
@@ -297,7 +294,6 @@ export const ReadTool = Tool.define<
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
|
||||
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
|
||||
|
||||
const mime = sniffAttachmentMime(sample, FSUtil.mimeType(filepath))
|
||||
@@ -312,7 +308,6 @@ export const ReadTool = Tool.define<
|
||||
metadata: {
|
||||
preview: msg,
|
||||
truncated: false,
|
||||
loaded: loaded.map((item) => item.filepath),
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
@@ -352,17 +347,12 @@ export const ReadTool = Tool.define<
|
||||
|
||||
yield* warm(filepath)
|
||||
|
||||
if (loaded.length > 0) {
|
||||
output += `\n\n<system-reminder>\n${loaded.map((item) => item.content).join("\n\n")}\n</system-reminder>`
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
output,
|
||||
metadata: {
|
||||
preview: file.raw.slice(0, 20).join("\n"),
|
||||
truncated,
|
||||
loaded: loaded.map((item) => item.filepath),
|
||||
display: {
|
||||
type: "file" as const,
|
||||
path: filepath,
|
||||
|
||||
@@ -46,7 +46,6 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { Question } from "../question"
|
||||
import { Todo } from "../session/todo"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Agent } from "../agent/agent"
|
||||
@@ -441,7 +440,6 @@ export const node = LayerNode.make({
|
||||
BackgroundJob.node,
|
||||
Provider.node,
|
||||
LSP.node,
|
||||
Instruction.node,
|
||||
FSUtil.node,
|
||||
EventV2Bridge.node,
|
||||
httpClient,
|
||||
|
||||
@@ -1031,7 +1031,6 @@ it.effect("global config remains global when project config is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.use.get()
|
||||
expect(config.model).toBe("global/model")
|
||||
expect(config.plugin_origins?.find((item) => item.spec === "global-plugin")?.scope).toBe("global")
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -1108,30 +1107,6 @@ it.effect("deduplicates duplicate plugins from global and local configs", () =>
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps plugin origins aligned with merged plugin list", () =>
|
||||
withConfigTree(
|
||||
{
|
||||
global: { plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"] },
|
||||
local: { plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"] },
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.use.get()
|
||||
const plugins = config.plugin ?? []
|
||||
const origins = config.plugin_origins ?? []
|
||||
const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item))
|
||||
|
||||
expect(names).toContain("shared-plugin@2.0.0")
|
||||
expect(names).not.toContain("shared-plugin@1.0.0")
|
||||
expect(names).toContain("global-only@1.0.0")
|
||||
expect(names).toContain("local-only@1.0.0")
|
||||
expect(origins.map((item) => item.spec)).toEqual(plugins)
|
||||
expect(origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")?.scope).toBe(
|
||||
"local",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Legacy tools migration tests
|
||||
|
||||
it.instance("migrates legacy tools config to permissions - allow", () =>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Effect, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { CurrentWorkingDirectory } from "@/config/tui-cwd"
|
||||
import { TuiConfig } from "../../src/config/tui"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
@@ -76,53 +75,7 @@ const getTuiConfig = (directory: string) =>
|
||||
),
|
||||
)
|
||||
|
||||
const getTuiPluginOrigins = (directory: string) =>
|
||||
TuiConfig.Service.use((svc) => svc.pluginOrigins()).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("keeps server and tui plugin merge semantics aligned", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
const local = path.join(test.directory, ".opencode")
|
||||
yield* fs.makeDirectory(local, { recursive: true })
|
||||
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "opencode.json"), {
|
||||
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
|
||||
})
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
|
||||
plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"],
|
||||
})
|
||||
yield* fs.writeJson(path.join(local, "opencode.json"), {
|
||||
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
||||
})
|
||||
yield* fs.writeJson(path.join(local, "tui.json"), {
|
||||
plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"],
|
||||
})
|
||||
|
||||
const server = yield* Config.use.get()
|
||||
const tui = yield* getTuiConfig(test.directory)
|
||||
const tuiOrigins = yield* getTuiPluginOrigins(test.directory)
|
||||
const serverPlugins = (server.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
||||
const tuiPlugins = (tui.plugin ?? []).map((item) => ConfigPlugin.pluginSpecifier(item))
|
||||
|
||||
expect(serverPlugins).toEqual(tuiPlugins)
|
||||
expect(serverPlugins).toContain("shared-plugin@2.0.0")
|
||||
expect(serverPlugins).not.toContain("shared-plugin@1.0.0")
|
||||
|
||||
const serverOrigins = server.plugin_origins ?? []
|
||||
expect(serverOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(serverPlugins)
|
||||
expect(tuiOrigins.map((item) => ConfigPlugin.pluginSpecifier(item.spec))).toEqual(tuiPlugins)
|
||||
expect(serverOrigins.map((item) => item.scope)).toEqual(tuiOrigins.map((item) => item.scope))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("loads tui config with the same precedence order as server config paths", () =>
|
||||
it.instance("merges plugin_enabled flags across config layers", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
@@ -739,93 +692,6 @@ it.instance("loads .opencode/tui.json", () =>
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("supports tuple plugin specs with options in tui.json", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]],
|
||||
})
|
||||
|
||||
const config = yield* getTuiConfig(test.directory)
|
||||
const origins = yield* getTuiPluginOrigins(test.directory)
|
||||
expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]])
|
||||
expect(origins).toEqual([
|
||||
{
|
||||
spec: ["acme-plugin@1.2.3", { enabled: true, label: "demo" }],
|
||||
scope: "local",
|
||||
source: path.join(test.directory, "tui.json"),
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("deduplicates tuple plugin specs by name with higher precedence winning", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), {
|
||||
plugin: [["acme-plugin@1.0.0", { source: "global" }]],
|
||||
})
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), {
|
||||
plugin: [
|
||||
["acme-plugin@2.0.0", { source: "project" }],
|
||||
["second-plugin@3.0.0", { source: "project" }],
|
||||
],
|
||||
})
|
||||
|
||||
const config = yield* getTuiConfig(test.directory)
|
||||
const origins = yield* getTuiPluginOrigins(test.directory)
|
||||
expect(config.plugin).toEqual([
|
||||
["acme-plugin@2.0.0", { source: "project" }],
|
||||
["second-plugin@3.0.0", { source: "project" }],
|
||||
])
|
||||
expect(origins).toEqual([
|
||||
{
|
||||
spec: ["acme-plugin@2.0.0", { source: "project" }],
|
||||
scope: "local",
|
||||
source: path.join(test.directory, "tui.json"),
|
||||
},
|
||||
{
|
||||
spec: ["second-plugin@3.0.0", { source: "project" }],
|
||||
scope: "local",
|
||||
source: path.join(test.directory, "tui.json"),
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("tracks global and local plugin metadata in merged tui config", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const test = yield* TestInstance
|
||||
yield* fs.writeJson(path.join(Global.Path.config, "tui.json"), { plugin: ["global-plugin@1.0.0"] })
|
||||
yield* fs.writeJson(path.join(test.directory, "tui.json"), { plugin: ["local-plugin@2.0.0"] })
|
||||
|
||||
const config = yield* getTuiConfig(test.directory)
|
||||
const origins = yield* getTuiPluginOrigins(test.directory)
|
||||
expect(config.plugin).toEqual(["global-plugin@1.0.0", "local-plugin@2.0.0"])
|
||||
expect(origins).toEqual([
|
||||
{
|
||||
spec: "global-plugin@1.0.0",
|
||||
scope: "global",
|
||||
source: path.join(Global.Path.config, "tui.json"),
|
||||
},
|
||||
{
|
||||
spec: "local-plugin@2.0.0",
|
||||
scope: "local",
|
||||
source: path.join(test.directory, "tui.json"),
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("merges plugin_enabled flags across config layers", () =>
|
||||
withCleanState(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const raw = process.argv[2]
|
||||
if (!raw) throw new Error("Missing worker payload")
|
||||
|
||||
const value = JSON.parse(raw)
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Invalid worker payload")
|
||||
}
|
||||
|
||||
const msg = Object.fromEntries(Object.entries(value))
|
||||
if (typeof msg.file !== "string" || typeof msg.spec !== "string" || typeof msg.target !== "string") {
|
||||
throw new Error("Invalid worker payload")
|
||||
}
|
||||
if (typeof msg.id !== "string") throw new Error("Invalid worker payload")
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = msg.file
|
||||
|
||||
const { PluginMeta } = await import("../../src/plugin/meta")
|
||||
|
||||
await PluginMeta.touch(msg.spec, msg.target, msg.id)
|
||||
Reference in New Issue
Block a user