Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda80da2c6 |
@@ -57,62 +57,6 @@ function stripQueryAndHash(input: string) {
|
||||
return input
|
||||
}
|
||||
|
||||
function unquoteGitPath(input: string) {
|
||||
if (!input.startsWith('"')) return input
|
||||
if (!input.endsWith('"')) return input
|
||||
const body = input.slice(1, -1)
|
||||
const bytes: number[] = []
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const char = body[i]!
|
||||
if (char !== "\\") {
|
||||
bytes.push(char.charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
const next = body[i + 1]
|
||||
if (!next) {
|
||||
bytes.push("\\".charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
if (next >= "0" && next <= "7") {
|
||||
const chunk = body.slice(i + 1, i + 4)
|
||||
const match = chunk.match(/^[0-7]{1,3}/)
|
||||
if (!match) {
|
||||
bytes.push(next.charCodeAt(0))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
bytes.push(parseInt(match[0], 8))
|
||||
i += match[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
const escaped =
|
||||
next === "n"
|
||||
? "\n"
|
||||
: next === "r"
|
||||
? "\r"
|
||||
: next === "t"
|
||||
? "\t"
|
||||
: next === "b"
|
||||
? "\b"
|
||||
: next === "f"
|
||||
? "\f"
|
||||
: next === "v"
|
||||
? "\v"
|
||||
: next === "\\" || next === '"'
|
||||
? next
|
||||
: undefined
|
||||
|
||||
bytes.push((escaped ?? next).charCodeAt(0))
|
||||
i++
|
||||
}
|
||||
|
||||
return new TextDecoder().decode(new Uint8Array(bytes))
|
||||
}
|
||||
|
||||
export function selectionFromLines(range: SelectedLineRange): FileSelection {
|
||||
const startLine = Math.min(range.start, range.end)
|
||||
const endLine = Math.max(range.start, range.end)
|
||||
@@ -253,7 +197,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
const root = directory()
|
||||
const prefix = root.endsWith("/") ? root : root + "/"
|
||||
|
||||
let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
|
||||
let path = stripQueryAndHash(stripFileProtocol(input))
|
||||
|
||||
if (path.startsWith(prefix)) {
|
||||
path = path.slice(prefix.length)
|
||||
|
||||
+367
-483
File diff suppressed because it is too large
Load Diff
@@ -1016,7 +1016,7 @@ export default function Page() {
|
||||
|
||||
const activeTab = createMemo(() => {
|
||||
const active = tabs().active()
|
||||
if (active) return normalizeTab(active)
|
||||
if (active) return active
|
||||
if (hasReview()) return "review"
|
||||
|
||||
const first = openedTabs()[0]
|
||||
|
||||
@@ -20,9 +20,6 @@ type HighlightGroup = {
|
||||
items: HighlightItem[]
|
||||
}
|
||||
|
||||
const ok = "public, max-age=1, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400"
|
||||
const error = "public, max-age=1, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400"
|
||||
|
||||
function parseHighlights(body: string): HighlightGroup[] {
|
||||
const groups = new Map<string, HighlightItem[]>()
|
||||
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
|
||||
@@ -94,30 +91,19 @@ export async function GET() {
|
||||
"User-Agent": "OpenCode-Console",
|
||||
},
|
||||
cf: {
|
||||
// best-effort edge caching (ignored outside Cloudflare)
|
||||
cacheTtl: 60 * 5,
|
||||
cacheEverything: true,
|
||||
},
|
||||
} as any).catch(() => undefined)
|
||||
} as RequestInit)
|
||||
|
||||
const fail = () =>
|
||||
new Response(JSON.stringify({ releases: [] }), {
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": error,
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
return Response.json({ releases: [] }, { status: 502 })
|
||||
}
|
||||
|
||||
if (!response?.ok) return fail()
|
||||
const releases = (await response.json()) as Release[]
|
||||
|
||||
const data = await response.json().catch(() => undefined)
|
||||
if (!Array.isArray(data)) return fail()
|
||||
|
||||
const releases = data as Release[]
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
return Response.json(
|
||||
{
|
||||
releases: releases.map((release) => {
|
||||
const parsed = parseMarkdown(release.body || "")
|
||||
return {
|
||||
@@ -129,12 +115,7 @@ export async function GET() {
|
||||
sections: parsed.sections,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": ok,
|
||||
},
|
||||
},
|
||||
{ headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600" } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import "./index.css"
|
||||
import { Title, Meta, Link } from "@solidjs/meta"
|
||||
import { createAsync } from "@solidjs/router"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { Header } from "~/component/header"
|
||||
import { Footer } from "~/component/footer"
|
||||
import { Legal } from "~/component/legal"
|
||||
import { config } from "~/config"
|
||||
import { For, Show, createSignal } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
|
||||
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
|
||||
|
||||
@@ -22,7 +21,7 @@ type HighlightGroup = {
|
||||
items: HighlightItem[]
|
||||
}
|
||||
|
||||
type ChangelogRelease = {
|
||||
type ParsedRelease = {
|
||||
tag: string
|
||||
name: string
|
||||
date: string
|
||||
@@ -31,16 +30,13 @@ type ChangelogRelease = {
|
||||
sections: { title: string; items: string[] }[]
|
||||
}
|
||||
|
||||
async function getReleases() {
|
||||
const event = getRequestEvent()
|
||||
const url = event ? new URL("/changelog.json", event.request.url).toString() : "/changelog.json"
|
||||
|
||||
const response = await fetch(url).catch(() => undefined)
|
||||
if (!response?.ok) return []
|
||||
|
||||
const json = await response.json().catch(() => undefined)
|
||||
return Array.isArray(json?.releases) ? (json.releases as ChangelogRelease[]) : []
|
||||
}
|
||||
const getReleases = query(async () => {
|
||||
"use server"
|
||||
const response = await fetch(`${config.baseUrl}/changelog.json`)
|
||||
if (!response.ok) return []
|
||||
const data = (await response.json()) as { releases: ParsedRelease[] }
|
||||
return data.releases
|
||||
}, "releases.get")
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString)
|
||||
@@ -149,47 +145,45 @@ export default function Changelog() {
|
||||
|
||||
<section data-component="releases">
|
||||
<For each={releases()}>
|
||||
{(release) => {
|
||||
return (
|
||||
<article data-component="release">
|
||||
<header>
|
||||
<div data-slot="version">
|
||||
<a href={release.url} target="_blank" rel="noopener noreferrer">
|
||||
{release.tag}
|
||||
</a>
|
||||
</div>
|
||||
<time dateTime={release.date}>{formatDate(release.date)}</time>
|
||||
</header>
|
||||
<div data-slot="content">
|
||||
<Show when={release.highlights.length > 0}>
|
||||
<div data-component="highlights">
|
||||
<For each={release.highlights}>{(group) => <HighlightSection group={group} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={release.highlights.length > 0 && release.sections.length > 0}>
|
||||
<CollapsibleSections sections={release.sections} />
|
||||
</Show>
|
||||
<Show when={release.highlights.length === 0}>
|
||||
<For each={release.sections}>
|
||||
{(section) => (
|
||||
<div data-component="section">
|
||||
<h3>{section.title}</h3>
|
||||
<ul>
|
||||
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
{(release) => (
|
||||
<article data-component="release">
|
||||
<header>
|
||||
<div data-slot="version">
|
||||
<a href={release.url} target="_blank" rel="noopener noreferrer">
|
||||
{release.tag}
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}}
|
||||
<time dateTime={release.date}>{formatDate(release.date)}</time>
|
||||
</header>
|
||||
<div data-slot="content">
|
||||
<Show when={release.highlights.length > 0}>
|
||||
<div data-component="highlights">
|
||||
<For each={release.highlights}>{(group) => <HighlightSection group={group} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={release.highlights.length > 0 && release.sections.length > 0}>
|
||||
<CollapsibleSections sections={release.sections} />
|
||||
</Show>
|
||||
<Show when={release.highlights.length === 0}>
|
||||
<For each={release.sections}>
|
||||
{(section) => (
|
||||
<div data-component="section">
|
||||
<h3>{section.title}</h3>
|
||||
<ul>
|
||||
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Legal />
|
||||
|
||||
@@ -73,7 +73,6 @@ export namespace Agent {
|
||||
const result: Record<string, Info> = {
|
||||
build: {
|
||||
name: "build",
|
||||
description: "The default agent. Executes tools based on configured permissions.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
@@ -88,7 +87,6 @@ export namespace Agent {
|
||||
},
|
||||
plan: {
|
||||
name: "plan",
|
||||
description: "Plan mode. Disallows all edit tools.",
|
||||
options: {},
|
||||
permission: PermissionNext.merge(
|
||||
defaults,
|
||||
|
||||
@@ -153,7 +153,6 @@ async function createToolContext(agent: Agent.Info) {
|
||||
callID: Identifier.ascending("part"),
|
||||
agent: agent.name,
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
|
||||
for (const pattern of req.patterns) {
|
||||
|
||||
@@ -1693,29 +1693,10 @@ function Glob(props: ToolProps<typeof GlobTool>) {
|
||||
}
|
||||
|
||||
function Read(props: ToolProps<typeof ReadTool>) {
|
||||
const { theme } = useTheme()
|
||||
const loaded = createMemo(() => {
|
||||
if (props.part.state.status !== "completed") return []
|
||||
if (props.part.state.time.compacted) return []
|
||||
const value = props.metadata.loaded
|
||||
if (!value || !Array.isArray(value)) return []
|
||||
return value.filter((p): p is string => typeof p === "string")
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
<For each={loaded()}>
|
||||
{(filepath) => (
|
||||
<box paddingLeft={3}>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
↳ Loaded {normalizePath(filepath)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
|
||||
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -206,11 +206,7 @@ export namespace File {
|
||||
const project = Instance.project
|
||||
if (project.vcs !== "git") return []
|
||||
|
||||
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
|
||||
|
||||
const changedFiles: Info[] = []
|
||||
|
||||
@@ -227,7 +223,7 @@ export namespace File {
|
||||
}
|
||||
}
|
||||
|
||||
const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
|
||||
const untrackedOutput = await $`git ls-files --others --exclude-standard`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
@@ -252,7 +248,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
// Get deleted files
|
||||
const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
|
||||
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
|
||||
.cwd(Instance.directory)
|
||||
.quiet()
|
||||
.nothrow()
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "../util/log"
|
||||
import type { MessageV2 } from "./message-v2"
|
||||
|
||||
const log = Log.create({ service: "instruction" })
|
||||
|
||||
const FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
|
||||
function globalFiles() {
|
||||
const files = [path.join(Global.Path.config, "AGENTS.md")]
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function resolveRelative(instruction: string): Promise<string[]> {
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
|
||||
}
|
||||
if (!Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.warn(
|
||||
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
|
||||
}
|
||||
|
||||
export namespace InstructionPrompt {
|
||||
export async function systemPaths() {
|
||||
const config = await Config.get()
|
||||
const paths = new Set<string>()
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of FILES) {
|
||||
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((p) => paths.add(path.resolve(p)))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of globalFiles()) {
|
||||
if (await Bun.file(file).exists()) {
|
||||
paths.add(path.resolve(file))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (config.instructions) {
|
||||
for (let instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
|
||||
if (instruction.startsWith("~/")) {
|
||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
||||
}
|
||||
const matches = path.isAbsolute(instruction)
|
||||
? await Array.fromAsync(
|
||||
new Bun.Glob(path.basename(instruction)).scan({
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).catch(() => [])
|
||||
: await resolveRelative(instruction)
|
||||
matches.forEach((p) => paths.add(path.resolve(p)))
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export async function system() {
|
||||
const config = await Config.get()
|
||||
const paths = await systemPaths()
|
||||
|
||||
const files = Array.from(paths).map(async (p) => {
|
||||
const content = await Bun.file(p)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
return content ? "Instructions from: " + p + "\n" + content : ""
|
||||
})
|
||||
|
||||
const urls: string[] = []
|
||||
if (config.instructions) {
|
||||
for (const instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
|
||||
urls.push(instruction)
|
||||
}
|
||||
}
|
||||
}
|
||||
const fetches = urls.map((url) =>
|
||||
fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.catch(() => "")
|
||||
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
|
||||
)
|
||||
|
||||
return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
|
||||
}
|
||||
|
||||
export function loaded(messages: MessageV2.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 async function find(dir: string) {
|
||||
for (const file of FILES) {
|
||||
const filepath = path.resolve(path.join(dir, file))
|
||||
if (await Bun.file(filepath).exists()) return filepath
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolve(messages: MessageV2.WithParts[], filepath: string) {
|
||||
const system = await systemPaths()
|
||||
const already = loaded(messages)
|
||||
const results: { filepath: string; content: string }[] = []
|
||||
|
||||
let current = path.dirname(path.resolve(filepath))
|
||||
const root = path.resolve(Instance.directory)
|
||||
|
||||
while (current.startsWith(root)) {
|
||||
const found = await find(current)
|
||||
if (found && !system.has(found) && !already.has(found)) {
|
||||
const content = await Bun.file(found)
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
if (content) {
|
||||
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
|
||||
}
|
||||
}
|
||||
if (current === root) break
|
||||
current = path.dirname(current)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -631,7 +631,7 @@ export namespace MessageV2 {
|
||||
sessionID: Identifier.schema("session"),
|
||||
messageID: Identifier.schema("message"),
|
||||
}),
|
||||
async (input): Promise<WithParts> => {
|
||||
async (input) => {
|
||||
return {
|
||||
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
|
||||
parts: await parts(input.messageID),
|
||||
|
||||
@@ -15,7 +15,6 @@ import { Instance } from "../project/instance"
|
||||
import { Bus } from "../bus"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { InstructionPrompt } from "./instruction"
|
||||
import { Plugin } from "../plugin"
|
||||
import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||
@@ -387,7 +386,6 @@ export namespace SessionPrompt {
|
||||
abort,
|
||||
callID: part.callID,
|
||||
extra: { bypassAgentCheck: true },
|
||||
messages: msgs,
|
||||
async metadata(input) {
|
||||
await Session.updatePart({
|
||||
...part,
|
||||
@@ -563,7 +561,6 @@ export namespace SessionPrompt {
|
||||
tools: lastUser.tools,
|
||||
processor,
|
||||
bypassAgentCheck,
|
||||
messages: msgs,
|
||||
})
|
||||
|
||||
if (step === 1) {
|
||||
@@ -601,7 +598,7 @@ export namespace SessionPrompt {
|
||||
agent,
|
||||
abort,
|
||||
sessionID,
|
||||
system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())],
|
||||
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
|
||||
messages: [
|
||||
...MessageV2.toModelMessages(sessionMessages, model),
|
||||
...(isLastStep
|
||||
@@ -653,7 +650,6 @@ export namespace SessionPrompt {
|
||||
tools?: Record<string, boolean>
|
||||
processor: SessionProcessor.Info
|
||||
bypassAgentCheck: boolean
|
||||
messages: MessageV2.WithParts[]
|
||||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
@@ -665,7 +661,6 @@ export namespace SessionPrompt {
|
||||
callID: options.toolCallId,
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
|
||||
agent: input.agent.name,
|
||||
messages: input.messages,
|
||||
metadata: async (val: { title?: string; metadata?: any }) => {
|
||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||
if (match && match.state.status === "running") {
|
||||
@@ -1013,7 +1008,6 @@ export namespace SessionPrompt {
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true, model },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
@@ -1075,7 +1069,6 @@ export namespace SessionPrompt {
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -20,62 +20,6 @@ import { Agent } from "@/agent/agent"
|
||||
export namespace SessionSummary {
|
||||
const log = Log.create({ service: "session.summary" })
|
||||
|
||||
function unquoteGitPath(input: string) {
|
||||
if (!input.startsWith('"')) return input
|
||||
if (!input.endsWith('"')) return input
|
||||
const body = input.slice(1, -1)
|
||||
const bytes: number[] = []
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const char = body[i]!
|
||||
if (char !== "\\") {
|
||||
bytes.push(char.charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
const next = body[i + 1]
|
||||
if (!next) {
|
||||
bytes.push("\\".charCodeAt(0))
|
||||
continue
|
||||
}
|
||||
|
||||
if (next >= "0" && next <= "7") {
|
||||
const chunk = body.slice(i + 1, i + 4)
|
||||
const match = chunk.match(/^[0-7]{1,3}/)
|
||||
if (!match) {
|
||||
bytes.push(next.charCodeAt(0))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
bytes.push(parseInt(match[0], 8))
|
||||
i += match[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
const escaped =
|
||||
next === "n"
|
||||
? "\n"
|
||||
: next === "r"
|
||||
? "\r"
|
||||
: next === "t"
|
||||
? "\t"
|
||||
: next === "b"
|
||||
? "\b"
|
||||
: next === "f"
|
||||
? "\f"
|
||||
: next === "v"
|
||||
? "\v"
|
||||
: next === "\\" || next === '"'
|
||||
? next
|
||||
: undefined
|
||||
|
||||
bytes.push((escaped ?? next).charCodeAt(0))
|
||||
i++
|
||||
}
|
||||
|
||||
return Buffer.from(bytes).toString()
|
||||
}
|
||||
|
||||
export const summarize = fn(
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
@@ -172,18 +116,7 @@ export namespace SessionSummary {
|
||||
messageID: Identifier.schema("message").optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
|
||||
const next = diffs.map((item) => {
|
||||
const file = unquoteGitPath(item.file)
|
||||
if (file === item.file) return item
|
||||
return {
|
||||
...item,
|
||||
file,
|
||||
}
|
||||
})
|
||||
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
|
||||
if (changed) Storage.write(["session_diff", input.sessionID], next).catch(() => {})
|
||||
return next
|
||||
return Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
|
||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
|
||||
import PROMPT_CODEX from "./prompt/codex_header.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
const log = Log.create({ service: "system-prompt" })
|
||||
|
||||
async function resolveRelativeInstruction(instruction: string): Promise<string[]> {
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
|
||||
}
|
||||
if (!Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.warn(
|
||||
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
|
||||
)
|
||||
return []
|
||||
}
|
||||
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
|
||||
}
|
||||
|
||||
export namespace SystemPrompt {
|
||||
export function instructions() {
|
||||
@@ -49,4 +72,81 @@ export namespace SystemPrompt {
|
||||
].join("\n"),
|
||||
]
|
||||
}
|
||||
|
||||
const LOCAL_RULE_FILES = [
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"CONTEXT.md", // deprecated
|
||||
]
|
||||
const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")]
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
|
||||
}
|
||||
|
||||
export async function custom() {
|
||||
const config = await Config.get()
|
||||
const paths = new Set<string>()
|
||||
|
||||
// Only scan local rule files when project discovery is enabled
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const localRuleFile of LOCAL_RULE_FILES) {
|
||||
const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
|
||||
if (matches.length > 0) {
|
||||
matches.forEach((path) => paths.add(path))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const globalRuleFile of GLOBAL_RULE_FILES) {
|
||||
if (await Bun.file(globalRuleFile).exists()) {
|
||||
paths.add(globalRuleFile)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const urls: string[] = []
|
||||
if (config.instructions) {
|
||||
for (let instruction of config.instructions) {
|
||||
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
|
||||
urls.push(instruction)
|
||||
continue
|
||||
}
|
||||
if (instruction.startsWith("~/")) {
|
||||
instruction = path.join(os.homedir(), instruction.slice(2))
|
||||
}
|
||||
let matches: string[] = []
|
||||
if (path.isAbsolute(instruction)) {
|
||||
matches = await Array.fromAsync(
|
||||
new Bun.Glob(path.basename(instruction)).scan({
|
||||
cwd: path.dirname(instruction),
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
}),
|
||||
).catch(() => [])
|
||||
} else {
|
||||
matches = await resolveRelativeInstruction(instruction)
|
||||
}
|
||||
matches.forEach((path) => paths.add(path))
|
||||
}
|
||||
}
|
||||
|
||||
const foundFiles = Array.from(paths).map((p) =>
|
||||
Bun.file(p)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
.then((x) => "Instructions from: " + p + "\n" + x),
|
||||
)
|
||||
const foundUrls = urls.map((url) =>
|
||||
fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.catch(() => "")
|
||||
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
|
||||
)
|
||||
return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ export namespace Snapshot {
|
||||
const git = gitdir()
|
||||
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
|
||||
const result =
|
||||
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
||||
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.worktree)
|
||||
.nothrow()
|
||||
@@ -196,7 +196,7 @@ export namespace Snapshot {
|
||||
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
|
||||
const git = gitdir()
|
||||
const result: FileDiff[] = []
|
||||
for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
|
||||
for await (const line of $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
|
||||
.quiet()
|
||||
.cwd(Instance.directory)
|
||||
.nothrow()
|
||||
|
||||
@@ -8,7 +8,6 @@ import DESCRIPTION from "./read.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Identifier } from "../id/id"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { InstructionPrompt } from "../session/instruction"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -60,8 +59,6 @@ export const ReadTool = Tool.define("read", {
|
||||
throw new Error(`File not found: ${filepath}`)
|
||||
}
|
||||
|
||||
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath)
|
||||
|
||||
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
|
||||
const isImage =
|
||||
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
|
||||
@@ -75,7 +72,6 @@ export const ReadTool = Tool.define("read", {
|
||||
metadata: {
|
||||
preview: msg,
|
||||
truncated: false,
|
||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
@@ -137,17 +133,12 @@ export const ReadTool = Tool.define("read", {
|
||||
LSP.touchFile(filepath, false)
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
if (instructions.length > 0) {
|
||||
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
output,
|
||||
metadata: {
|
||||
preview,
|
||||
truncated,
|
||||
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ export namespace Tool {
|
||||
abort: AbortSignal
|
||||
callID?: string
|
||||
extra?: { [key: string]: any }
|
||||
messages: MessageV2.WithParts[]
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { InstructionPrompt } from "../../src/session/instruction"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("InstructionPrompt.resolve", () => {
|
||||
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
|
||||
await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const system = await InstructionPrompt.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
||||
|
||||
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"))
|
||||
expect(results).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const system = await InstructionPrompt.systemPaths()
|
||||
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
||||
|
||||
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "subdir", "nested", "file.ts"))
|
||||
expect(results.length).toBe(1)
|
||||
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,6 @@ const baseCtx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ const ctx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ const ctx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const ctx = {
|
||||
callID: "test-call",
|
||||
agent: "test-agent",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const ctx = {
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
@@ -331,26 +330,3 @@ root_type Monster;`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read loaded instructions", () => {
|
||||
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
|
||||
await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
|
||||
expect(result.output).toContain("test content")
|
||||
expect(result.output).toContain("system-reminder")
|
||||
expect(result.output).toContain("Test Instructions")
|
||||
expect(result.metadata.loaded).toBeDefined()
|
||||
expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,20 +3,19 @@ import { ComponentProps, JSXElement, ParentProps, splitProps } from "solid-js"
|
||||
|
||||
export interface HoverCardProps extends ParentProps, Omit<ComponentProps<typeof Kobalte>, "children"> {
|
||||
trigger: JSXElement
|
||||
mount?: HTMLElement
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
export function HoverCard(props: HoverCardProps) {
|
||||
const [local, rest] = splitProps(props, ["trigger", "mount", "class", "classList", "children"])
|
||||
const [local, rest] = splitProps(props, ["trigger", "class", "classList", "children"])
|
||||
|
||||
return (
|
||||
<Kobalte gutter={4} {...rest}>
|
||||
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
|
||||
{local.trigger}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal mount={local.mount}>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
data-component="hover-card-content"
|
||||
classList={{
|
||||
|
||||
@@ -88,7 +88,7 @@ export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills
|
||||
|
||||
When opencode starts, it looks for rule files in this order:
|
||||
|
||||
1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`)
|
||||
1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`, or `CONTEXT.md`)
|
||||
2. **Global file** at `~/.config/opencode/AGENTS.md`
|
||||
3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user