Compare commits

...

86 Commits

Author SHA1 Message Date
opencode 43ecd3432a release: v1.17.0 2026-06-10 03:12:21 +00:00
Dmitriy Kovalenko e9e2612706 chore: Update fff to 0.9.4 (#31583) 2026-06-09 22:28:34 -04:00
opencode-agent[bot] 4ae468ff06 chore: generate 2026-06-10 02:12:40 +00:00
Dax Raad 785918262a fix(core): expose partial filesystem scan results 2026-06-09 22:10:53 -04:00
opencode-agent[bot] be8fee5c2b chore: generate 2026-06-10 02:05:59 +00:00
Dax Raad 4597c687fa fix(core): prefer shorter paths for tied search scores 2026-06-09 22:04:17 -04:00
Dax c4aa049042 fix(tui): let gutter replace current marker (#31586) 2026-06-09 21:07:11 -04:00
opencode-agent[bot] 1d46b5c33c chore: update nix node_modules hashes 2026-06-10 00:51:11 +00:00
opencode-agent[bot] 6ae6f0fe8f chore: generate 2026-06-10 00:39:58 +00:00
Dax a0409e64d8 refactor(core): unify filesystem search service (#31566) 2026-06-09 20:38:02 -04:00
opencode-agent[bot] ce4e658e3f chore: update nix node_modules hashes 2026-06-09 23:55:49 +00:00
opencode-agent[bot] 215fb39fc5 chore: generate 2026-06-09 23:36:28 +00:00
Luke Parker 1cc94bb23e fix(desktop): update Electron stack and panel layout (#31571) 2026-06-09 23:34:35 +00:00
Frank bc42187c58 zen: update email 2026-06-09 19:31:54 -04:00
Songchao Wang 80c0b06980 feat: add X-Session-Id header for proxy cache routing affinity (#31511) 2026-06-09 17:01:17 -05:00
Aiden Cline 381eabb970 refactor(mcp): simplify service helpers (#31549) 2026-06-09 16:59:24 -05:00
opencode-agent[bot] c939aa04fe chore: generate 2026-06-09 19:34:54 +00:00
Frank 9b8e356fca zen: add claude fable 5 2026-06-09 15:32:14 -04:00
James Long 07e5ea9367 feat(opencode): add typed application layer graph (#31531) 2026-06-09 15:31:31 -04:00
opencode-agent[bot] 7a54a2c49c chore: generate 2026-06-09 19:10:44 +00:00
Dax 600e405ba0 fix(opencode): restore effect error logging (#31551) 2026-06-09 19:09:04 +00:00
opencode-agent[bot] 1daf535e6b chore: update nix node_modules hashes 2026-06-09 18:45:17 +00:00
opencode-agent[bot] cc52dc396c chore: generate 2026-06-09 18:30:51 +00:00
Dax 132ef57272 refactor(core): simplify location filesystem (#31545) 2026-06-09 14:28:45 -04:00
Aiden Cline 0777cf1ccf fix(mcp): log actionable connection statuses (#31544) 2026-06-09 13:07:56 -05:00
Aiden Cline c4bc902958 fix(opencode): support Claude Fable reasoning (#31546) 2026-06-09 12:38:53 -05:00
Adam d68397b42f fix(data): timestamp formatting 2026-06-09 12:19:05 -05:00
Frank 531eebb88e zen: add north mini code model 2026-06-09 13:03:55 -04:00
Aiden Cline db9391e8a6 drop citation_options from cohere (#31543) 2026-06-09 11:53:02 -05:00
Adam f1c31f4eba fix(stats): use data branding assets 2026-06-09 11:34:38 -05:00
opencode-agent[bot] ada5b31bb0 chore: generate 2026-06-09 16:12:42 +00:00
James Long ffcb45d7c9 feat(tui): show project copy in session list (#31421) 2026-06-09 12:10:37 -04:00
Dax 6566ede935 refactor(core): consolidate references (#31539) 2026-06-09 16:08:58 +00:00
Aiden Cline 0bb677cef9 feat(opencode): configure Cohere North model (#31536) 2026-06-09 10:54:03 -05:00
opencode-agent[bot] 8415ff50cb chore: generate 2026-06-09 15:48:12 +00:00
Dax 37522185d3 refactor(tui): centralize application exit (#31524) 2026-06-09 11:46:02 -04:00
Aiden Cline 960eacebcf test(core): avoid Windows worker close race (#31532) 2026-06-09 10:31:19 -05:00
Adam b4a6419215 chore: stats -> data 2026-06-09 09:31:21 -05:00
Sebastian 7c1e61af1e leave a breadcrumb comment about batchWindow zero (#31508) 2026-06-09 13:48:59 +02:00
opencode-agent[bot] 671d193786 docs: add uninstall troubleshooting steps (#31424)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-09 10:49:18 +02:00
Simon Klee 2ababc87da core: fix idle CPU use in file logger (#31478) 2026-06-09 09:27:32 +02:00
Luke Parker 5372c63c7c fix(app): clip rounded session panels (#31462) 2026-06-09 06:22:52 +00:00
Ben Sandbrook ab701d20eb feat: add "reasoning" as interleaved field option for vLLM providers (#30477)
Co-authored-by: Ben Sandbrook <1126483+delta9000@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-06-08 23:16:05 -05:00
Brendan Allan 384a8f58c6 feat(app): tabs help button (#31454) 2026-06-09 12:09:08 +08:00
opencode-agent[bot] 50c9d5221f chore: generate 2026-06-09 03:57:22 +00:00
Brendan Allan 1a08ee77c7 feat(app): draft prompt state (#31452) 2026-06-09 03:56:04 +00:00
Aiden Cline 79cff288a6 fix(opencode): pass abort signal to MCP tool calls (#31455) 2026-06-08 22:47:05 -05:00
Aiden Cline 0efc334ff9 fix(opencode): paginate MCP catalogs (#31442) 2026-06-08 22:43:55 -05:00
opencode-agent[bot] 277ecc505c chore: generate 2026-06-09 02:50:41 +00:00
Brendan Allan f565ff3c09 feat(app): add draft tab support to tabs store (#31343) 2026-06-09 10:49:18 +08:00
Anthony Lau 161247c70d fix(opencode): generate reasoning variants for all OpenRouter models. (#30332)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-08 20:22:02 -05:00
Aiden Cline a86ecf3bba fix: adjust item id stripping to happen prior to request signing (#31429) 2026-06-08 17:43:48 -05:00
Aiden Cline 6e84142b59 fix(opencode): support MiniMax M3 thinking toggle (#31426) 2026-06-08 17:03:31 -05:00
James Long fc52c5ac87 fix(tui): trim select footer action highlight (#31411) 2026-06-08 17:53:06 -04:00
opencode-agent[bot] 537666149b chore: generate 2026-06-08 19:43:41 +00:00
Dax c06ad7c881 refactor(core): replace legacy logger with Effect logging (#31310) 2026-06-08 15:41:56 -04:00
Aiden Cline 0a7cb20e66 fix(opencode): await run event loop (#31389) 2026-06-08 12:47:39 -05:00
Shoubhit Dash f43209bb8c fix(session): avoid sticky prompt tool overrides (#31394) 2026-06-08 23:03:27 +05:30
Shoubhit Dash b34d9242d1 fix(core): disable fff trace logs (#31380) 2026-06-08 21:10:09 +05:30
opencode-agent[bot] 9654412021 chore: update nix node_modules hashes 2026-06-08 15:16:59 +00:00
Simon Klee 31d2fecf63 chore: upgrade OpenTUI to v0.3.4 (#31326) 2026-06-08 17:00:19 +02:00
opencode-agent[bot] f3f59db3ae chore: update nix node_modules hashes 2026-06-08 14:45:45 +00:00
Aiden Cline 89e2a23b5b fix(core): restore npm proxy agent patch (#31373) 2026-06-08 09:32:41 -05:00
opencode-agent[bot] ea5f97609a chore: generate 2026-06-08 14:15:38 +00:00
Shoubhit Dash 4119051077 fix: stabilize fff file results (#31369) 2026-06-08 19:43:38 +05:30
Adam f116a55e4a fix(stats): show new for leaderboard deltas 2026-06-08 08:47:35 -05:00
opencode-agent[bot] 89e371c94b chore: generate 2026-06-08 13:41:54 +00:00
Shoubhit Dash b1a6c40ad0 fix: speed up fff file search (#31366) 2026-06-08 19:09:55 +05:30
Jack 1772e8ee6e docs(go): update MiniMax M3 pricing (#31350) 2026-06-08 19:16:19 +08:00
Tommy D. Rossi 0050134d9e fix(session): merge per-call tool rules into session permission (#30529)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-06-08 13:02:10 +05:30
opencode-agent[bot] d46af9cf1e chore: generate 2026-06-08 07:00:27 +00:00
huangli bea56feb63 fix(lsp): resolve JDTLS root to topmost pom.xml in Java Maven multi-module projects (#28761)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-06-08 12:28:53 +05:30
Shoubhit Dash 685a894a6f fix(opencode): include acp pending tool input (#31321) 2026-06-08 12:20:59 +05:30
Frank 79ea379df2 zen: fix 2026-06-08 01:31:31 -04:00
Frank 4863aedf3d console: update email 2026-06-08 01:05:45 -04:00
Aiden Cline b5cb9aae7f fix(opencode): respect MCP server capabilities (#31271) 2026-06-07 21:24:41 -05:00
opencode-agent[bot] 4d09a71ef4 chore: generate 2026-06-08 02:07:18 +00:00
Filip 65a3f7f749 fix(desktop): few WSL bugs (#31095) 2026-06-08 02:05:54 +00:00
Luke Parker b1d14acc35 chore: update web and desktop code owners (#31289) 2026-06-08 12:00:51 +10:00
opencode-agent[bot] 3867fa2bad chore: generate 2026-06-07 21:27:42 +00:00
Simon Klee 07808bea12 run: make minimal mode more minimal (#31227) 2026-06-07 23:26:14 +02:00
opencode-agent[bot] 914a643ab2 chore: update nix node_modules hashes 2026-06-07 18:48:26 +00:00
opencode-agent[bot] c495635f05 chore: generate 2026-06-07 18:35:32 +00:00
Aiden Cline 8ff40133b4 chore(opencode): update MCP SDK to 1.29.0 (#31268) 2026-06-07 13:34:10 -05:00
Aiden Cline aacdb34e3f fix(opencode): avoid duplicate skill catalog (#31269) 2026-06-07 13:34:01 -05:00
Aiden Cline 233427f08e test(core): cover skill directory output (#31263) 2026-06-07 12:37:53 -05:00
503 changed files with 10905 additions and 12815 deletions
+2 -4
View File
@@ -1,5 +1,3 @@
# web + desktop packages
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin
packages/app/ @Hona @Brendonovich
packages/desktop/ @Hona @Brendonovich
+1 -23
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: bun turbo test:ci --log-order=stream --log-prefix=task
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -74,26 +74,6 @@ jobs:
working-directory: packages/opencode
run: bun run test:httpapi
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e:
name: e2e (${{ matrix.settings.name }})
strategy:
@@ -151,7 +131,6 @@ jobs:
run: bun --cwd packages/app test:e2e:local
env:
CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30
- name: Upload Playwright artifacts
@@ -162,6 +141,5 @@ jobs:
if-no-files-found: ignore
retention-days: 7
path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results
packages/app/e2e/playwright-report
+319 -88
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -165,7 +165,7 @@ export const app = new sst.cloudflare.x.SolidStart("Stats", {
domain: `stats.${domain}`,
link: [database, EMAILOCTOPUS_API_KEY],
environment: {
PUBLIC_URL: `https://${domain}/stats`,
PUBLIC_URL: `https://${domain}/data`,
},
})
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yZeq16sWAtsAHZO3pbsr90t3+8PlOueRym2J/Kgpj1U=",
"aarch64-linux": "sha256-m54/gm6R8MyQXBh68K8McAfZ9nLLK08nYQwMvAsTs8o=",
"aarch64-darwin": "sha256-pbtcF4ZCUqKO/SVLxv4wskl+O1LlT2RNsUV5d4s9+WY=",
"x86_64-darwin": "sha256-o3ucdbFNy2y9Hrb594Y9AtpRBUPglhqZLtTyiqMECR8="
"x86_64-linux": "sha256-aaxzlA+w1zjduftCSHzJwLr3ntP5BBNNgEs+xyn7/MA=",
"aarch64-linux": "sha256-X9ncgFqlq/3vazlu5kY73Bt85CB4i92kYIO67vvA4t4=",
"aarch64-darwin": "sha256-kiTv4u+8WGZw7S+96GZ5VovbwUVGgtYUmeh50iDIcDA=",
"x86_64-darwin": "sha256-hr32zroPe2K4wijwH5BkE257N3UIZPEWDG2rGvD+jKc="
}
}
+5 -4
View File
@@ -39,9 +39,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.2",
"@opentui/keymap": "0.3.2",
"@opentui/solid": "0.3.2",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -140,7 +140,8 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.16.2",
"version": "1.17.0",
"description": "",
"type": "module",
"exports": {
@@ -18,8 +18,7 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
+1 -7
View File
@@ -7,12 +7,6 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({
testDir: "./e2e",
outputDir: "./e2e/test-results",
@@ -24,7 +18,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
webServer: {
command,
url: baseURL,
@@ -407,6 +407,7 @@ export function DialogSelectFile(props: {
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
@@ -509,11 +509,16 @@ export function useServerManagementController(options: { onSelect?: () => void;
resetEdit()
})
async function handleRemove(url: ServerConnection.Key) {
tabs.removeServer(url)
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
}
}
@@ -0,0 +1,54 @@
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
const [shown, setShown] = createSignal(false)
return (
<Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50">
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs="button"
triggerProps={{
type: "button",
"aria-label": "Help",
class:
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
}}
trigger={<span aria-hidden="true">?</span>}
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={8}
placement="top-end"
>
<Show when={shown()}>
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
<button
type="button"
aria-label="Close"
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
onClick={() => {
setShown(false)
setState("dismissed", true)
}}
>
<Icon name="xmark-small" />
</button>
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
<p class="text-12-regular text-text-weak">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
</Show>
</Popover>
</div>
</Show>
)
}
@@ -658,6 +658,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
key: atKey,
filterKeys: ["display"],
skipFilter: (item) => item.type === "file" && !item.recent,
groupBy: (item) => {
if (item.type === "agent") return "agent"
if (item.recent) return "recent"
@@ -3,7 +3,13 @@ import { serverAttachmentFile } from "./server-attachment"
describe("serverAttachmentFile", () => {
test("creates a file from server text content", async () => {
const file = serverAttachmentFile("docs/readme.txt", { type: "text", content: "hello", mime: "text/plain" })
const file = serverAttachmentFile("docs/readme.txt", {
uri: "file:///docs/readme.txt",
name: "readme.txt",
content: "hello",
encoding: "utf8",
mime: "text/plain",
})
expect(file.name).toBe("readme.txt")
expect(file.type).toBe("text/plain")
@@ -12,7 +18,8 @@ describe("serverAttachmentFile", () => {
test("creates a file from server base64 content", async () => {
const file = serverAttachmentFile("images/pixel.png", {
type: "binary",
uri: "file:///images/pixel.png",
name: "pixel.png",
content: "aGVsbG8=",
encoding: "base64",
mime: "image/png",
@@ -1,8 +1,8 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSystemBinaryContent, FileSystemTextContent } from "@opencode-ai/sdk/v2"
import type { FileSystemContent } from "@opencode-ai/sdk/v2"
export function serverAttachmentFile(path: string, data: FileSystemTextContent | FileSystemBinaryContent) {
export function serverAttachmentFile(path: string, data: FileSystemContent) {
const content =
data.type === "text" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0))
data.encoding === "utf8" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0))
return new File([content], getFilename(path), { type: data.mime })
}
+2
View File
@@ -447,6 +447,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
refreshTabsAreOverflowing()
})
if (tab.type !== "session") return null
return (
<>
{i() !== 0 && (
+21 -12
View File
@@ -1,6 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
@@ -153,9 +153,11 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type Scope = {
dir: string
id?: string
type Scope = { draftID: string } | { dir: string; id?: string }
function scopeKey(scope: Scope) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
}
type PromptCacheEntry = {
@@ -163,11 +165,15 @@ type PromptCacheEntry = {
dispose: VoidFunction
}
function createPromptSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
function promptTarget(serverScope: ServerScope, scope: Scope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
Persist.serverScoped(scope, dir, id, "prompt", [legacy]),
promptTarget(serverScope, scope),
createStore<{
prompt: Prompt
cursor?: number
@@ -231,6 +237,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false,
init: () => {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>()
@@ -254,8 +261,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const owner = getOwner()
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const load = (scope: Scope) => {
const key = scopeKey(scope)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -265,7 +272,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(serverSDK.scope, dir, id),
value: createPromptSession(serverSDK.scope, scope),
dispose,
}),
owner,
@@ -276,8 +283,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
)
const pick = (scope?: Scope) => (scope ? load(scope) : session())
return {
ready: () => session().ready,
+64 -5
View File
@@ -2,10 +2,12 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
export type SessionTab = {
@@ -15,10 +17,22 @@ export type SessionTab = {
sessionId: string
}
export type Tab = SessionTab
export type DraftTab = {
type: "draft"
draftID: string
server: ServerConnection.Key
directory: string
worktree?: string
}
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
export type Tab = SessionTab | DraftTab
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory)
@@ -33,6 +47,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
gate: false,
init: () => {
const server = useServer()
const platform = usePlatform()
const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
@@ -53,6 +68,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const closing = new Set<string>()
const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
createEffect(() => {
if (!ready()) return
const servers = new Set(server.list.map(ServerConnection.key))
@@ -83,10 +102,42 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}),
)
},
draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
const draftID = uuid()
setStore(
produce((tabs) => {
tabs.push({ type: "draft", draftID, ...draft })
}),
)
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
setStore(
(tab) => tab.type === "draft" && tab.draftID === draftID,
produce((tab) => Object.assign(tab, draft)),
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
const active = `${location.pathname}${location.search}` === draftHref(draftID)
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index !== -1) tabs[index] = { type: "session", ...session }
}),
)
if (active) navigateTab({ type: "session", ...session })
removeDraftPersisted(draftID)
},
removeTab: (index: number) => {
const tab = store[index]
if (!tab) return
const key = tabKey(tab)
const draftID = tab.type === "draft" ? tab.draftID : undefined
const nextTab = store[index + 1] ?? store[index - 1]
closing.add(key)
void startTransition(() => {
@@ -98,9 +149,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
if (draftID) removeDraftPersisted(draftID)
},
removeServer(key: ServerConnection.Key) {
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
@@ -110,7 +164,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const sessionIDs = new Set(input.sessionIDs)
const currentHref =
params.dir && params.id
? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })
? tabHref({
type: "session",
server: server.key,
dirBase64: params.dir,
sessionId: params.id,
})
: undefined
const currentIndex = currentHref
? tabs.findIndex(
+3
View File
@@ -63,6 +63,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
@@ -2364,6 +2365,7 @@ export default function Layout(props: ParentProps) {
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
}
@@ -2517,6 +2519,7 @@ export default function Layout(props: ParentProps) {
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
</Show>
+77 -61
View File
@@ -38,6 +38,7 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
@@ -51,6 +52,7 @@ import {
createSizing,
focusTerminalById,
shouldFocusTerminalOnKeyDown,
shouldShowFileTree,
} from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
@@ -194,6 +196,7 @@ export default function Page() {
const sdk = useSDK()
const serverSDK = useServerSDK()
const settings = useSettings()
const platform = usePlatform()
const prompt = usePrompt()
const comments = useComments()
const terminal = useTerminal()
@@ -271,7 +274,16 @@ export default function Page() {
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(
() =>
isDesktop() &&
!isV2NewSessionPage() &&
shouldShowFileTree({
desktopV2: platform.platform === "desktop" && settings.general.newLayoutDesigns(),
showFileTree: settings.general.showFileTree(),
opened: layout.fileTree.opened(),
}),
)
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
const sessionPanelWidth = createMemo(() => {
if (!desktopSidePanelOpen()) return "100%"
@@ -1745,81 +1757,85 @@ export default function Page() {
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!size.active() && !ui.reviewSnap,
"transition-[width]": !isV2NewSessionPage(),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
style={{
width: sessionPanelWidth(),
}}
>
<div
class="flex-1 min-h-0 overflow-hidden"
classList={{
"rounded-[10px]": settings.general.newLayoutDesigns(),
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-deep": isV2NewSessionPage(),
"bg-background-stronger": !isV2NewSessionPage(),
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
>
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>
<Match when={true}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
const root = scroller
if (root) scheduleScrollState(root)
}}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>
<Match when={true}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
<ResizeHandle
@@ -8,8 +8,17 @@ import {
focusTerminalById,
getTabReorderIndex,
shouldFocusTerminalOnKeyDown,
shouldShowFileTree,
} from "./helpers"
describe("shouldShowFileTree", () => {
test("does not reserve space for a disabled v2 file tree", () => {
expect(shouldShowFileTree({ desktopV2: true, showFileTree: false, opened: true })).toBe(false)
expect(shouldShowFileTree({ desktopV2: false, showFileTree: false, opened: true })).toBe(true)
expect(shouldShowFileTree({ desktopV2: true, showFileTree: true, opened: true })).toBe(true)
})
})
describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => {
const calls: string[] = []
@@ -20,6 +20,10 @@ type TabsInput = {
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
export function shouldShowFileTree(input: { desktopV2: boolean; showFileTree: boolean; opened: boolean }) {
return input.opened && (!input.desktopV2 || input.showFileTree)
}
export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
@@ -24,7 +24,13 @@ import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import {
createOpenSessionFileTab,
createSessionTabs,
getTabReorderIndex,
shouldShowFileTree,
type Sizing,
} from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
@@ -59,10 +65,18 @@ export function SessionSidePanel(props: {
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const shown = createMemo(() => !desktopV2() || settings.general.showFileTree())
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
const fileOpen = createMemo(
() =>
isDesktop() &&
shouldShowFileTree({
desktopV2: desktopV2(),
showFileTree: settings.general.showFileTree(),
opened: layout.fileTree.opened(),
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
+18
View File
@@ -166,6 +166,24 @@ describe("persist localStorage resilience", () => {
expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
})
test("draft target isolates storage per draft and namespaces keys", () => {
const a = Persist.draft("draft-a", "prompt")
const b = Persist.draft("draft-b", "prompt")
expect(a.key).toBe("draft:prompt")
expect(a.storage).not.toBe(b.storage)
expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage)
})
test("removes draft storage when removing persisted target", () => {
const target = Persist.draft("draft-a", "prompt")
storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
removePersisted(target)
expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
})
test("server workspace target preserves local storage and isolates remote storage", () => {
const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")
+15
View File
@@ -341,6 +341,12 @@ function workspaceStorage(dir: string) {
return `opencode.workspace.${head}.${sum}.dat`
}
function draftStorage(draftID: string) {
const head = (draftID.slice(0, 12) || "draft").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(draftID) ?? "0"
return `opencode.draft.${head}.${sum}.dat`
}
function legacyWorkspaceStorage(dir: string) {
const storage = workspaceStorage(pathKey(dir))
const result = new Set<string>()
@@ -450,6 +456,12 @@ function localStorageDirect(): SyncStorage {
}
}
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
export function draftPersistedKeys() {
return DRAFT_PERSISTED_KEYS
}
export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
@@ -462,6 +474,9 @@ export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
},
serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget {
if (scope === ServerScope.local) return Persist.global(key, legacy)
return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }
+11 -11
View File
@@ -71,6 +71,17 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
@@ -106,17 +117,6 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === selectedDistro()
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
+1 -5
View File
@@ -76,11 +76,7 @@ export function WslServerSettings(props: {
}))
const remove = (key: ServerConnection.Key) => {
if (!api) return
request.mutate(async () => {
await api.removeServer(key)
await props.controller.handleRemove(key)
})
request.mutate(() => props.controller.handleRemove(key))
}
return (
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.16.2",
"version": "1.17.0",
"type": "module",
"license": "MIT",
"bin": {
+2
View File
@@ -92,6 +92,8 @@ for (const item of targets) {
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
OTUI_TREE_SITTER_WORKER_PATH:
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
path.relative(dir, parserWorker).replaceAll("\\", "/") +
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.16.2",
"version": "1.17.0",
"type": "module",
"license": "MIT",
"scripts": {
+17 -4
View File
@@ -1,6 +1,8 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
const dataPath = "/data"
export async function statsProxy(evt: APIEvent) {
const req = evt.request.clone()
const targetUrl = new URL(req.url)
@@ -8,8 +10,8 @@ export async function statsProxy(evt: APIEvent) {
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
targetUrl.port = ""
if (targetUrl.pathname.startsWith("/stats/_build/") || targetUrl.pathname === "/stats/banner.png") {
targetUrl.pathname = targetUrl.pathname.slice("/stats".length)
if (targetUrl.pathname.startsWith(`${dataPath}/_build/`) || targetUrl.pathname === `${dataPath}/banner.jpg`) {
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
}
const response = await fetch(targetUrl, {
@@ -32,6 +34,17 @@ export async function statsProxy(evt: APIEvent) {
})
}
function rewriteStatsHtml(html: string) {
return html.replaceAll('"/_build/', '"/stats/_build/').replaceAll("'/_build/", "'/stats/_build/")
export function statsRedirect(evt: APIEvent) {
const url = new URL(evt.request.url)
url.pathname = `${dataPath}${url.pathname.slice("/stats".length)}`
return new Response(null, {
status: 308,
headers: {
Location: url.toString(),
},
})
}
function rewriteStatsHtml(html: string) {
return html.replaceAll('"/_build/', `"${dataPath}/_build/`).replaceAll("'/_build/", `'${dataPath}/_build/`)
}
@@ -0,0 +1,8 @@
import { statsProxy } from "~/lib/stats-proxy"
export const GET = statsProxy
export const POST = statsProxy
export const PUT = statsProxy
export const DELETE = statsProxy
export const OPTIONS = statsProxy
export const PATCH = statsProxy
@@ -0,0 +1,8 @@
import { statsProxy } from "~/lib/stats-proxy"
export const GET = statsProxy
export const POST = statsProxy
export const PUT = statsProxy
export const DELETE = statsProxy
export const OPTIONS = statsProxy
export const PATCH = statsProxy
+1 -1
View File
@@ -65,7 +65,7 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" },
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
{ id: "minimax-m3", name: "MiniMax M3", req: 1400, d: "200ms" },
{ id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "200ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "210ms" },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "220ms" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "230ms" },
@@ -501,7 +501,7 @@ export default function PrivacyPolicy() {
otherwise use the Services or send us any Personal Data. If we learn we have collected Personal Data
from a child under 18 years of age, we will delete that information as quickly as possible. If you
believe that a child under 18 years of age may have provided Personal Data to us, please contact us at{" "}
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
<a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h2 id="california-resident-rights">California Resident Rights</h2>
@@ -520,7 +520,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a California resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access</h3>
@@ -605,7 +605,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Colorado resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -676,7 +676,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Connecticut resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -745,7 +745,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Delaware resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -818,7 +818,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are an Iowa resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -864,7 +864,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Montana resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -937,7 +937,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Nebraska resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1007,7 +1007,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a New Hampshire resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1078,7 +1078,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a New Jersey resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1151,7 +1151,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are an Oregon resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1225,7 +1225,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Texas resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1293,7 +1293,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Utah resident, the portion that is more protective of Personal Data shall control to the extent of
such conflict. If you have any questions about this section or whether any of the following rights apply
to you, please contact us at contact@anoma.ly.
to you, please contact us at help@anoma.ly.
</p>
<h3>Access and Portability</h3>
@@ -1339,7 +1339,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Virginia resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1418,7 +1418,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1430,7 +1430,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1457,7 +1457,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1474,8 +1474,8 @@ export default function PrivacyPolicy() {
<p>
Under California Civil Code Sections 1798.83-1798.84, California residents are entitled to contact us to
prevent disclosure of Personal Data to third parties for such third parties' direct marketing purposes;
in order to submit such a request, please contact us at{" "}
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
in order to submit such a request, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
.
</p>
<p>
@@ -1500,7 +1500,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
Email: <a href="mailto:help@anoma.ly">help@anoma.ly</a>
</li>
<li>Phone: +1 415 794-0209</li>
<li>Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States</li>
@@ -30,7 +30,7 @@ export default function TermsOfService() {
</p>
<p>
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
Email: <a href="mailto:help@anoma.ly">help@anoma.ly</a>
</p>
<p>
@@ -114,7 +114,7 @@ export default function TermsOfService() {
attempt to register for or otherwise use the Services or send us any personal information. If we learn
we have collected personal information from a child under 13 years of age, we will delete that
information as quickly as possible. If you believe that a child under 13 years of age may have provided
us personal information, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
us personal information, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h2 id="what-are-the-basics">What are the basics of using OpenCode?</h2>
@@ -315,7 +315,7 @@ export default function TermsOfService() {
specified time of the trial. You must stop using a Paid Service before the end of the trial period in
order to avoid being charged for that Paid Service. If you cancel prior to the end of the trial period
and are inadvertently charged for a Paid Service, please contact us at{" "}
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
<a href="mailto:help@anoma.ly">help@anoma.ly</a>.
</p>
<h2 id="what-if-i-want-to-stop">What if I want to stop using the Services?</h2>
@@ -1,8 +1,8 @@
import { statsProxy } from "~/lib/stats-proxy"
import { statsRedirect } from "~/lib/stats-proxy"
export const GET = statsProxy
export const POST = statsProxy
export const PUT = statsProxy
export const DELETE = statsProxy
export const OPTIONS = statsProxy
export const PATCH = statsProxy
export const GET = statsRedirect
export const POST = statsRedirect
export const PUT = statsRedirect
export const DELETE = statsRedirect
export const OPTIONS = statsRedirect
export const PATCH = statsRedirect
@@ -1,8 +1,8 @@
import { statsProxy } from "~/lib/stats-proxy"
import { statsRedirect } from "~/lib/stats-proxy"
export const GET = statsProxy
export const POST = statsProxy
export const PUT = statsProxy
export const DELETE = statsProxy
export const OPTIONS = statsProxy
export const PATCH = statsProxy
export const GET = statsRedirect
export const POST = statsRedirect
export const PUT = statsRedirect
export const DELETE = statsRedirect
export const OPTIONS = statsRedirect
export const PATCH = statsRedirect
@@ -226,7 +226,7 @@ export async function POST(input: APIEvent) {
expand: ["discounts", "payments"],
})
const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string
const couponID = (invoice.discounts[0] as Stripe.Discount).coupon?.id as string
const couponID = (invoice.discounts[0] as Stripe.Discount)?.coupon?.id as string
if (!paymentID) {
// payment id can be undefined when using coupon
if (!couponID) throw new Error("Payment ID not found")
@@ -143,7 +143,7 @@ export function BillingSection() {
<h2>{i18n.t("workspace.billing.title")}</h2>
<p>
{i18n.t("workspace.billing.subtitle.beforeLink")}{" "}
<a href="mailto:contact@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
<a href="mailto:help@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
{i18n.t("workspace.billing.subtitle.afterLink")}
</p>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.16.2",
"version": "1.17.0",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.16.2",
"version": "1.17.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.16.2",
"version": "1.17.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.16.2",
"version": "1.17.0",
"type": "module",
"license": "MIT",
"scripts": {
+3 -4
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.16.2",
"version": "1.17.0",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -9,8 +9,7 @@
"db": "bun drizzle-kit",
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"test": "bun test",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit"
},
"bin": {
@@ -86,7 +85,7 @@
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@ff-labs/fff-bun": "0.9.3",
"@ff-labs/fff-bun": "0.9.4",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
@@ -0,0 +1,65 @@
export * as ConfigReferencePlugin from "./reference"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
export const Plugin = {
id: PluginV2.ID.make("core/config-reference"),
effect: Effect.gen(function* () {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
const references = yield* Reference.Service
const update = yield* references.transform()
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
entries.set(
name,
local(entry)
? new Reference.LocalSource({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
})
: new Reference.GitSource({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
}),
)
}
}
yield* update((editor) => {
for (const [name, source] of entries) editor.add(name, source)
})
}),
}
function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name)
}
function local(entry: ConfigReference.Entry): entry is string | ConfigReference.Local {
return typeof entry === "string"
? entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")
: "path" in entry
}
function localPath(directory: string, home: string, value: string) {
if (value.startsWith("~/")) return path.join(home, value.slice(2))
return path.isAbsolute(value) ? value : path.resolve(directory, value)
}
-30
View File
@@ -16,33 +16,3 @@ export type Entry = typeof Entry.Type
export const Info = Schema.Record(Schema.String, Entry)
export type Info = typeof Info.Type
export type NormalizedEntry =
| { readonly kind: "local"; readonly path: string }
| { readonly kind: "git"; readonly repository: string; readonly branch?: string }
| { readonly kind: "invalid"; readonly message: string }
export type NormalizedInfo = Record<string, NormalizedEntry>
export function validateAlias(name: string) {
if (name.length === 0) return "Reference alias must not be empty"
if (/[\/\s`,]/.test(name)) return "Reference alias must not contain /, whitespace, comma, or backtick"
}
export function normalizeEntry(entry: Entry): NormalizedEntry {
if (typeof entry === "string") {
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) return { kind: "local", path: entry }
return { kind: "git", repository: entry }
}
if ("path" in entry) return { kind: "local", path: entry.path }
return { kind: "git", repository: entry.repository, branch: entry.branch }
}
export function normalize(info: Info): NormalizedInfo {
return Object.fromEntries(
Object.entries(info).map(([name, entry]) => {
const message = validateAlias(name)
return [name, message ? { kind: "invalid" as const, message } : normalizeEntry(entry)]
}),
)
}
+3
View File
@@ -24,6 +24,8 @@ import {
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
import { LayerNode } from "./effect/layer-node"
import { filesystem, path } from "./effect/layer-node-platform"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
@@ -501,5 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
export const node = LayerNode.make(layer, [filesystem, path])
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
+3
View File
@@ -8,6 +8,7 @@ import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { LayerNode } from "../effect/layer-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
@@ -58,3 +59,5 @@ export const defaultLayer = Layer.unwrap(
return layerFromPath(path())
}),
).pipe(Layer.provide(Global.defaultLayer))
export const node = LayerNode.make(layerFromPath(path()), [])
@@ -0,0 +1,12 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { FetchHttpClient } from "effect/unstable/http"
import { LayerNode } from "./layer-node"
export const filesystem = LayerNode.make(NodeFileSystem.layer, [])
export const path = LayerNode.make(NodePath.layer, [])
export const httpClient = LayerNode.make(FetchHttpClient.layer, [])
export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient])
export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor])
export * as LayerNodePlatform from "./layer-node-platform"
+95
View File
@@ -0,0 +1,95 @@
import { Layer } from "effect"
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
type AnyNode = Node<unknown, unknown>
type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
Missing<Layer.Services<Implementation>, Dependencies>,
] extends [never]
? unknown
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
declare const $OutputType: unique symbol
declare const $ErrorType: unique symbol
export type Node<A, E = never> = {
readonly kind: "layer" | "group"
readonly implementation?: Layer.Any
readonly dependencies: readonly AnyNode[]
readonly [$OutputType]?: () => A
readonly [$ErrorType]?: () => E
}
export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
implementation: Implementation,
dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
}
export function group<const Items extends NodeList>(
dependencies: Items,
): Node<Output<Items[number]>, Error<Items[number]>> {
return { kind: "group", dependencies }
}
export type Replacement<A = unknown> = {
readonly source: Node<A, unknown>
readonly replacement: Node<A, unknown>
}
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
export function replace<A, E, E2>(
source: Node<A, E>,
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
): Replacement<A> {
return { source, replacement }
}
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
const cache = new Map<AnyNode, RuntimeLayer>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const ids = new Map<AnyNode, number>()
const visit = (input: AnyNode): RuntimeLayer => {
const node = replacements.get(input) ?? input
const cached = cache.get(node)
if (cached) return cached
if (visiting.has(node)) {
const start = stack.indexOf(node)
const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ")
throw new Error(`Cycle detected in app graph: ${cycle}`)
}
if (!ids.has(node)) ids.set(node, ids.size + 1)
visiting.add(node)
stack.push(node)
try {
const dependencies = node.dependencies.map(visit)
const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]]
const result =
node.kind === "group"
? dependencies.length === 0
? Layer.empty
: Layer.mergeAll(...nonEmpty)
: dependencies.length === 0
? (node.implementation as RuntimeLayer)
: Layer.provide(node.implementation as RuntimeLayer, nonEmpty)
cache.set(node, result)
return result
} finally {
stack.pop()
visiting.delete(node)
}
}
return visit(node) as unknown as Layer.Layer<A, E, never>
}
export * as LayerNode from "./layer-node"
-73
View File
@@ -1,73 +0,0 @@
import { Cause, Effect, Logger, References } from "effect"
import * as Log from "../util/log"
type Fields = Record<string, unknown>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(
Object.entries(input ?? {})
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => [normalizeKey(key), value]),
)
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
// oxlint-disable-next-line no-base-to-string
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
-107
View File
@@ -1,107 +0,0 @@
import { Effect, Layer, Logger } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "../flag/flag"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { ensureProcessMetadata } from "../util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, ...value] = x.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
const processMetadata = ensureProcessMetadata("main")
const attributes: Record<string, string> = (() => {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
})()
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...attributes,
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
"opencode.run_id": processMetadata.runID,
"service.instance.id": processID,
},
}
}
function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
// @effect/opentelemetry creates a NodeTracerProvider but never calls
// register(), so the global @opentelemetry/api context manager stays
// as the no-op default. Non-Effect code (like the AI SDK) that calls
// tracer.startActiveSpan() relies on context.active() to find the
// parent span - without a real context manager every span starts a
// new trace. Registering AsyncLocalStorageContextManager fixes this.
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
const mgr = new AsyncLocalStorageContextManager()
mgr.enable()
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
headers,
}),
),
}))
}
export const layer = !base
? EffectLogger.layer
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs())
}),
)
export const Observability = { enabled, layer }
+1 -1
View File
@@ -1,6 +1,6 @@
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
import { memoMap } from "./memo-map"
import { Observability } from "./observability"
import { Observability } from "../observability"
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+3 -3
View File
@@ -7,6 +7,7 @@ import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
@@ -410,9 +411,7 @@ export const layerWith = (options?: LayerOptions) =>
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) =>
Effect.logError("Event observer failed").pipe(
Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }),
),
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
),
)
@@ -676,5 +675,6 @@ export const layerWith = (options?: LayerOptions) =>
)
export const layer = layerWith()
export const node = LayerNode.make(layer, [Database.node])
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+79 -580
View File
@@ -2,205 +2,51 @@ export * as FileSystem from "./filesystem"
import path from "path"
import { pathToFileURL } from "url"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import { Context, Effect, Layer, Option, Schema, Stream } from "effect"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { ProjectReference } from "./project-reference"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Protected } from "./filesystem/protected"
import { Ripgrep } from "./filesystem/ripgrep"
import { ToolOutputStore } from "./tool-output-store"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, Match } from "./filesystem/schema"
export { Entry, Match, Submatch } from "./filesystem/schema"
export const ReadInput = Schema.Struct({
path: Schema.String,
reference: Schema.NonEmptyString.pipe(Schema.optional),
path: RelativePath,
})
export type ReadInput = typeof ReadInput.Type
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const READ_SAMPLE_BYTES = 4 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
export class BinaryFileError extends Error {
constructor(readonly resource: string) {
super(`Cannot read binary file: ${resource}`)
this.name = "BinaryFileError"
}
}
const BINARY_EXTENSIONS = new Set([
".zip",
".tar",
".gz",
".exe",
".dll",
".so",
".class",
".jar",
".war",
".7z",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
".odt",
".ods",
".odp",
".bin",
".dat",
".obj",
".o",
".a",
".lib",
".wasm",
".pyc",
".pyo",
])
export const isBinary = (resource: string, bytes: Uint8Array) => {
if (BINARY_EXTENSIONS.has(path.extname(resource).toLowerCase())) return true
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const supportedImageMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
}
export class MediaIngestLimitError extends Error {
constructor(
readonly resource: string,
readonly maximumBytes: number,
) {
super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
this.name = "MediaIngestLimitError"
}
}
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
type: Schema.Literal("text"),
export const Content = Schema.Struct({
uri: Schema.String,
name: Schema.String.pipe(Schema.optional),
content: Schema.String,
encoding: Schema.Literals(["utf8", "base64"]),
mime: Schema.String,
}) {}
export class BinaryContent extends Schema.Class<BinaryContent>("FileSystem.BinaryContent")({
type: Schema.Literal("binary"),
content: Schema.String,
encoding: Schema.Literal("base64"),
mime: Schema.String,
}) {}
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
}).annotate({ identifier: "FileSystem.Content" })
export type Content = typeof Content.Type
export const TextPageInput = Schema.Struct({
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
})
export type TextPageInput = typeof TextPageInput.Type
export class TextPage extends Schema.Class<TextPage>("FileSystem.TextPage")({
type: Schema.Literal("text-page"),
content: Schema.String,
mime: Schema.String,
offset: PositiveInt,
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export class ReadPath extends Schema.Class<ReadPath>("FileSystem.ReadPath")({
type: Schema.Literals(["file", "directory"]),
resource: Schema.String,
}) {}
export const ListInput = Schema.Struct({
path: Schema.String.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional),
path: RelativePath.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
export const ListPageInput = Schema.Struct({
...ListInput.fields,
offset: PositiveInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(2_000)).pipe(Schema.optional),
})
export type ListPageInput = typeof ListPageInput.Type
export class ListTarget extends Schema.Class<ListTarget>("FileSystem.ListTarget")({
absolute: Schema.String,
real: Schema.String,
directory: Schema.String,
root: Schema.String,
resource: Schema.String,
}) {}
/** Canonical root and permission resource for Location-scoped search. */
export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget")({
real: Schema.String,
root: Schema.String,
resource: Schema.String,
reference: Schema.NonEmptyString.pipe(Schema.optional),
type: Schema.Literals(["file", "directory"]),
}) {}
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
path: RelativePath,
uri: Schema.String,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
export class ListPage extends Schema.Class<ListPage>("FileSystem.ListPage")({
entries: Schema.Array(Entry),
truncated: Schema.Boolean,
next: PositiveInt.pipe(Schema.optional),
}) {}
export const FindInput = Schema.Struct({
export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type FindInput = typeof FindInput.Type
}) {}
export const GrepInput = Schema.Struct({
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String,
path: RelativePath.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
}) {}
export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
pattern: Schema.String,
path: RelativePath.pipe(Schema.optional),
include: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type
export class GrepMatch extends Schema.Class<GrepMatch>("FileSystem.GrepMatch")({
path: RelativePath,
lines: Schema.String,
line: PositiveInt,
offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}) {}
export const Event = {
@@ -214,435 +60,88 @@ export const Event = {
export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<Content>
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPath>
readonly readTool: (input: ReadInput, page?: TextPageInput) => Effect.Effect<Content | TextPage>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
/** Resolve a contained canonical search root and its permission resource. */
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
readonly resolveList: (input?: ListInput) => Effect.Effect<ListTarget>
readonly listResolved: (target: ListTarget) => Effect.Effect<Entry[]>
readonly listPage: (input?: ListPageInput) => Effect.Effect<ListPage>
readonly listPageResolved: (
target: ListTarget,
page?: Pick<ListPageInput, "offset" | "limit">,
) => Effect.Effect<ListPage>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
export const layer = Layer.effect(
const baseLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const references = yield* ProjectReference.Service
const ripgrep = yield* Ripgrep.Service
const search = yield* FileSystemSearch.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const ignored = ignore()
const gitignore = yield* fs
.readFileString(path.join(location.project.directory, ".gitignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (gitignore) ignored.add(gitignore)
const ignorefile = yield* fs
.readFileString(path.join(location.project.directory, ".ignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (ignorefile) ignored.add(ignorefile)
const select = Effect.fnUntraced(function* (reference?: string) {
if (!reference) return { directory: location.directory, root }
const resolved = yield* references.get(reference)
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
})
const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (input && path.isAbsolute(input)) {
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
const real = yield* fs.realPath(input).pipe(Effect.orDie)
const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie)
if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_"))
return yield* Effect.die(new Error("Path escapes managed tool output"))
return { absolute: input, real, directory: managed, root: managedRoot }
}
const selected = yield* select(reference)
const absolute = path.resolve(selected.directory, input ?? ".")
if (!FSUtil.contains(selected.directory, absolute))
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, ...selected }
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
})
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!real) return
if (!FSUtil.contains(selected.root, real)) return
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
if (!info) return
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
if (!type) return
return new Entry({
path: RelativePath.make(path.relative(selected.directory, absolute)),
uri: pathToFileURL(real).href,
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real),
})
})
const scan = Effect.fnUntraced(function* () {
if (location.directory === Global.Path.home && location.project.id === "global") {
const protectedNames = Protected.names()
const nested = new Set(["node_modules", "dist", "build", "target", "vendor"])
return (yield* Effect.forEach(
yield* fs.readDirectoryEntries(location.directory).pipe(Effect.orElseSucceed(() => [])),
(item) =>
Effect.gen(function* () {
if (item.type !== "directory" || item.name.startsWith(".") || protectedNames.has(item.name)) return []
const directory = path.join(location.directory, item.name)
return [
item.name + "/",
...(yield* fs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => []))).flatMap((child) =>
child.type === "directory" && !child.name.startsWith(".") && !nested.has(child.name)
? [`${item.name}/${child.name}/`]
: [],
),
]
}),
)).flat()
}
const files = Array.from(yield* ripgrep.files({ cwd: location.directory }).pipe(Stream.runCollect, Effect.orDie))
const dirs = new Set<string>()
for (const file of files) {
let current = file
while (true) {
const directory = path.dirname(current)
if (directory === "." || directory === current) break
current = directory
dirs.add(directory + "/")
}
}
return [...files, ...dirs]
})
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
const target = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new ReadPath({
type,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
})
})
const resolveFile = Effect.fnUntraced(function* (input: ReadInput) {
const target = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return {
real: target.real,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
}
})
const content = (target: { readonly real: string }, bytes: Uint8Array) =>
Effect.gen(function* () {
return Service.of({
find: search.find,
glob: search.glob,
grep: search.grep,
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const bytes = yield* fs.readFile(target.real).pipe(Effect.orDie)
const mime = FSUtil.mimeType(target.real)
if (!bytes.includes(0)) {
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
Effect.option,
)
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
}
return new BinaryContent({
type: "binary",
content: Buffer.from(bytes).toString("base64"),
encoding: "base64",
mime,
})
})
const readTool = Effect.fn("FileSystem.readTool")(function* (input: ReadInput, page: TextPageInput = {}) {
const target = yield* resolveFile(input)
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || READ_SAMPLE_BYTES)).pipe(Effect.orDie),
() => new Uint8Array(),
)
const mime = supportedImageMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file
.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
.pipe(Effect.orDie)
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
return new BinaryContent({
type: "binary",
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64",
if (Option.isSome(content))
return {
uri: pathToFileURL(target.real).href,
name: path.basename(target.real),
content: content.value,
encoding: "utf8" as const,
mime,
})
}
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || isBinary(target.resource, first))
return yield* Effect.die(new BinaryFileError(target.resource))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(target.resource))
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
}
text.push(yield* Effect.sync(() => decoder.decode()))
return new TextContent({ type: "text", content: text.join(""), mime: FSUtil.mimeType(target.real) })
}
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
let discard = false
let line = 1
let bytes = 0
let found = false
let truncated = false
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
truncated = true
next ??= line
line++
return
}
found = true
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
truncated = true
next ??= line
line++
return
}
lines.push(text)
bytes += size
line++
}
const consume = (chunk: Uint8Array) => {
if (chunk.includes(0)) throw new BinaryFileError(target.resource)
let text = decoder.decode(chunk, { stream: true })
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
append(current.endsWith("\r") ? current.slice(0, -1) : current)
}
}
yield* Effect.sync(() => consume(first))
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
yield* Effect.sync(() => consume(chunk.value))
}
const tail = yield* Effect.sync(() => decoder.decode())
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
const text = lines.join("\n")
return new TextPage({
type: "text-page",
content: text,
mime: FSUtil.mimeType(target.real),
offset,
truncated,
...(next === undefined ? {} : { next }),
})
}),
)
})
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
const directory = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
return new ListTarget({
...directory,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
})
})
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
const target = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new RootTarget({
...target,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
reference: input.reference,
type,
})
})
const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) {
return yield* fs.readDirectoryEntries(directory.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
})
const listPageResolved = Effect.fn("FileSystem.listPageResolved")(function* (
target: ListTarget,
page: Pick<ListPageInput, "offset" | "limit"> = {},
) {
type Candidate = Entry | { readonly name: string; readonly type: "file" | "directory" }
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? 2_000, 2_000)
const items = yield* fs.readDirectoryEntries(target.real).pipe(Effect.orDie)
const candidates = yield* Effect.forEach(
items,
(item): Effect.Effect<Candidate | undefined> => {
if (item.type === "other") return Effect.succeed(undefined)
if (item.type === "symlink") return entry(path.join(target.absolute, item.name), target)
return Effect.succeed({ name: item.name, type: item.type } as const)
},
{ concurrency: 16 },
).pipe(Effect.map((items) => items.filter((item): item is Candidate => item !== undefined)))
candidates.sort((a, b) => {
return a.type === b.type
? (a instanceof Entry ? a.path : a.name).localeCompare(b instanceof Entry ? b.path : b.name)
: a.type === "directory"
? -1
: 1
})
const selected = candidates.slice(offset - 1, offset - 1 + limit)
const entries = yield* Effect.forEach(
selected,
(item) => (item instanceof Entry ? Effect.succeed(item) : entry(path.join(target.absolute, item.name), target)),
{
concurrency: 16,
},
).pipe(Effect.map((items) => items.filter((item): item is Entry => item !== undefined)))
const truncated = offset - 1 + selected.length < candidates.length
return new ListPage({ entries, truncated, ...(truncated ? { next: offset + selected.length } : {}) })
})
return Service.of({
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolveFile(input)
return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie))
}
return {
uri: pathToFileURL(target.real).href,
name: path.basename(target.real),
content: Buffer.from(bytes).toString("base64"),
encoding: "base64" as const,
mime,
}
}),
resolveReadPath,
readTool,
list: Effect.fn("FileSystem.list")(function* (input) {
return yield* listResolved(yield* resolveList(input))
}),
resolveRoot,
resolveList,
listResolved,
listPage: Effect.fn("FileSystem.listPage")(function* (input) {
return yield* listPageResolved(yield* resolveList(input), input)
}),
listPageResolved,
find: Effect.fn("FileSystem.find")(function* (input) {
const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/"))
const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/"))
const sorted = input.query.trim()
? fuzzysort.go(input.query.trim(), filtered, { limit: input.limit ?? 100 }).map((item) => item.target)
: filtered.slice(0, input.limit)
return yield* Effect.forEach(sorted, (item) => entry(path.join(location.directory, item))).pipe(
Effect.map((items) => items.filter((item): item is Entry => item !== undefined)),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.map((items) =>
items
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.join(target.absolute, item.name)
const relative = path.relative(target.directory, absolute)
return [
new Entry({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
}),
]
})
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
grep: Effect.fn("FileSystem.grep")(function* (input) {
return (yield* ripgrep
.search({
cwd: location.directory,
pattern: input.pattern,
glob: input.include ? [input.include] : undefined,
limit: input.limit,
})
.pipe(Effect.orDie)).items.map(
(item) =>
new GrepMatch({
path: RelativePath.make(item.path.text),
lines: item.lines.text,
line: item.line_number,
offset: item.absolute_offset,
submatches: item.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
end: submatch.end,
})),
}),
)
}),
isIgnored: (input, type) =>
ignored.ignores(
path.relative(location.project.directory, path.join(location.directory, input)) +
(type === "directory" ? "/" : ""),
),
})
}),
)
export const locationLayer = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(ProjectReference.locationLayer),
)
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer
+4
View File
@@ -12,6 +12,10 @@ import {
type SearchResult,
} from "@ff-labs/fff-bun"
declare global {
const FFF_LIBC: "gnu" | "musl"
}
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export type Init = InitOptions
-485
View File
@@ -1,485 +0,0 @@
import path from "path"
import { serviceUse } from "../effect/service-use"
import { FSUtil } from "../fs-util"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { Global } from "../global"
import { NonNegativeInt } from "../schema"
import * as Log from "../util/log"
import { sanitizedProcessEnv } from "../util/opencode-process"
import { which } from "../util/which"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
const TimeStats = Schema.Struct({
secs: NonNegativeInt,
nanos: NonNegativeInt,
human: Schema.String,
})
const Stats = Schema.Struct({
elapsed: TimeStats,
searches: NonNegativeInt,
searches_with_match: NonNegativeInt,
bytes_searched: NonNegativeInt,
bytes_printed: NonNegativeInt,
matched_lines: NonNegativeInt,
matches: NonNegativeInt,
})
const PathText = Schema.Struct({
text: Schema.String,
})
const Begin = Schema.Struct({
type: Schema.Literal("begin"),
data: Schema.Struct({
path: PathText,
}),
})
export const SearchMatch = Schema.Struct({
path: PathText,
lines: Schema.Struct({
text: Schema.String,
}),
line_number: NonNegativeInt,
absolute_offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
})
export const Match = Schema.Struct({
type: Schema.Literal("match"),
data: SearchMatch,
})
const End = Schema.Struct({
type: Schema.Literal("end"),
data: Schema.Struct({
path: PathText,
binary_offset: Schema.NullOr(NonNegativeInt),
stats: Stats,
}),
})
const Summary = Schema.Struct({
type: Schema.Literal("summary"),
data: Schema.Struct({
elapsed_total: TimeStats,
stats: Stats,
}),
})
const Result = Schema.Union([Begin, Match, End, Summary])
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
export type Result = Schema.Schema.Type<typeof Result>
export type Match = Schema.Schema.Type<typeof Match>
export type Item = Match["data"]
export type Begin = Schema.Schema.Type<typeof Begin>
export type End = Schema.Schema.Type<typeof End>
export type Summary = Schema.Schema.Type<typeof Summary>
export type Row = Match["data"]
export interface SearchResult {
items: Item[]
partial: boolean
}
export interface FilesInput {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
signal?: AbortSignal
}
export interface SearchInput {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
signal?: AbortSignal
}
export interface TreeInput {
cwd: string
limit?: number
signal?: AbortSignal
}
export interface Interface {
readonly filepath: Effect.Effect<string, Error>
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const use = serviceUse(Service)
function env() {
const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
function aborted(signal?: AbortSignal) {
const err = signal?.reason
if (err instanceof Error) return err
const out = new Error("Aborted")
out.name = "AbortError"
return out
}
function waitForAbort(signal?: AbortSignal) {
if (!signal) return Effect.never
if (signal.aborted) return Effect.fail(aborted(signal))
return Effect.callback<never, Error>((resume) => {
const onabort = () => resume(Effect.fail(aborted(signal)))
signal.addEventListener("abort", onabort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", onabort))
})
}
function error(stderr: string, code: number) {
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
err.name = "RipgrepError"
return err
}
function clean(file: string) {
return path.normalize(file.replace(/^\.[\\/]/, ""))
}
function row(data: Row): Row {
return {
...data,
path: {
...data.path,
text: clean(data.path.text),
},
}
}
function parse(line: string) {
return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
}
function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
Queue.failCauseUnsafe(queue, Cause.fail(err))
}
function filesArgs(input: FilesInput) {
const args = ["--no-config", "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.hidden === false) args.push("--glob=!.*")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const glob of input.glob) args.push(`--glob=${glob}`)
}
args.push(".")
return args
}
function searchArgs(input: SearchInput) {
const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const glob of input.glob) args.push(`--glob=${glob}`)
}
if (input.limit) args.push(`--max-count=${input.limit}`)
args.push("--", input.pattern, ...(input.file ?? ["."]))
return args
}
function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
}
export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpawner | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const spawner = yield* ChildProcessSpawner
const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [stdout, stderr, code] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
return { stdout, stderr, code }
}, Effect.scoped)
const extract = Effect.fnUntraced(function* (
archive: string,
config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string,
) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
if (config.extension === "zip") {
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
const result = yield* run(shell, [
"-NoProfile",
"-NonInteractive",
"-Command",
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
])
if (result.code !== 0) {
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
}
}
if (config.extension === "tar.gz") {
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
if (result.code !== 0) {
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
}
}
const extracted = path.join(
dir,
`ripgrep-${VERSION}-${config.platform}`,
process.platform === "win32" ? "rg.exe" : "rg",
)
if (!(yield* fs.isFile(extracted))) {
return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
}
yield* fs.copyFile(extracted, target)
if (process.platform === "win32") return
yield* fs.chmod(target, 0o755)
}, Effect.scoped)
const filepath = yield* Effect.cached(
Effect.gen(function* () {
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) {
return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
}
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
log.info("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
)
if (bytes.byteLength === 0) {
return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
}
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
yield* extract(archive, config, target)
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
return target
}),
)
const check = Effect.fnUntraced(function* (cwd: string) {
if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
return yield* Effect.fail(
Object.assign(new Error(`No such file or directory: '${cwd}'`), {
code: "ENOENT",
errno: -2,
path: cwd,
}),
)
})
const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
const binary = yield* filepath
return ChildProcess.make(binary, args, {
cwd,
env: env(),
extendEnv: true,
stdin: "ignore",
})
})
const files: Interface["files"] = (input) =>
Stream.callback<string, PlatformError | Error>((queue) =>
Effect.gen(function* () {
yield* Effect.forkScoped(
Effect.gen(function* () {
yield* check(input.cwd)
const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
const stdout = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
Effect.forkScoped,
)
const code = yield* raceAbort(handle.exitCode, input.signal)
yield* Fiber.join(stdout)
if (code === 0 || code === 1) {
Queue.endUnsafe(queue)
return
}
fail(queue, error(yield* Fiber.join(stderr), code))
}).pipe(
Effect.catch((err) =>
Effect.sync(() => {
fail(queue, err)
}),
),
),
)
}),
)
const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
yield* check(input.cwd)
const program = Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
const [items, stderr, code] = yield* Effect.all(
[
Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(parse),
Stream.filter((item): item is Match => item.type === "match"),
Stream.map((item) => row(item.data)),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
if (code !== 0 && code !== 1 && code !== 2) {
return yield* Effect.fail(error(stderr, code))
}
return {
items: code === 1 ? [] : items,
partial: code === 2,
}
}),
)
return yield* raceAbort(program, input.signal)
})
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
log.info("tree", input)
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
interface Node {
name: string
children: Map<string, Node>
}
function child(node: Node, name: string) {
const item = node.children.get(name)
if (item) return item
const next = { name, children: new Map() }
node.children.set(name, next)
return next
}
function count(node: Node): number {
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
}
const root: Node = { name: "", children: new Map() }
for (const file of list) {
if (file.includes(".opencode")) continue
const parts = file.split(path.sep)
if (parts.length < 2) continue
let node = root
for (const part of parts.slice(0, -1)) {
node = child(node, part)
}
}
const total = count(root)
const limit = input.limit ?? total
const lines: string[] = []
const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: node.name }))
let used = 0
for (let i = 0; i < queue.length && used < limit; i++) {
const item = queue[i]
lines.push(item.path)
used++
queue.push(
...Array.from(item.node.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: `${item.path}/${node.name}` })),
)
}
if (total > used) lines.push(`[${total - used} truncated]`)
return lines.join("\n")
})
return Service.of({ filepath, files, tree, search })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export * as Ripgrep from "./ripgrep"
+23
View File
@@ -0,0 +1,23 @@
import { Schema } from "effect"
import { NonNegativeInt, PositiveInt, RelativePath } from "../schema"
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
path: RelativePath,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
export const Submatch = Schema.Struct({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
})
export type Submatch = typeof Submatch.Type
export class Match extends Schema.Class<Match>("FileSystem.Match")({
entry: Entry,
line: PositiveInt,
offset: NonNegativeInt,
text: Schema.String,
submatches: Schema.Array(Submatch),
}) {}
+218 -532
View File
@@ -1,553 +1,239 @@
export * as FileSystemSearch from "./search"
import path from "path"
import { Context, Deferred, Effect, Layer, Option, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FSUtil } from "../fs-util"
import { Glob } from "../util/glob"
import { Global } from "../global"
import * as Log from "../util/log"
import { serviceUse } from "../effect/service-use"
import { makeRuntime } from "../effect/runtime"
import { Context, Effect, Layer, Scope } from "effect"
import { Fff } from "#fff"
import { Ripgrep } from "./ripgrep"
const log = Log.create({ service: "file.search" })
const root = path.join(Global.Path.cache, "fff")
export type Item = Ripgrep.Item
export type SearchError = PlatformError | globalThis.Error
export interface Result {
readonly items: Item[]
readonly partial: boolean
readonly hasNextPage: boolean
readonly engine: "fff" | "ripgrep"
readonly regexFallbackError?: string
}
export interface FileInput {
readonly cwd: string
readonly query: string
readonly limit?: number
readonly current?: string
readonly kind?: "file" | "directory" | "all"
}
export interface GlobInput {
readonly cwd: string
readonly pattern: string
readonly limit?: number
readonly signal?: AbortSignal
}
interface Query {
readonly dir: string
readonly text: string
readonly files: string[]
}
// A created picker plus its cached scan-readiness gate. The picker is created
// (and its native background scan kicked off) eagerly; `ready` is only awaited
// when the picker is actually used.
interface Picker {
readonly pick: Fff.Picker
readonly ready: Effect.Effect<void, Error>
}
interface State {
readonly pick: Map<string, Picker>
readonly wait: Map<string, Deferred.Deferred<Picker, Error>>
readonly recent: Query[]
}
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
export interface Interface {
readonly files: Ripgrep.Interface["files"]
readonly tree: Ripgrep.Interface["tree"]
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, SearchError>
readonly file: (input: FileInput) => Effect.Effect<string[] | undefined, SearchError>
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError>
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void, SearchError>
readonly warm: (cwd: string) => Effect.Effect<void>
// Destroy the picker for a directory and drop its cached state. Called when a
// directory's instance is disposed so fff's native watcher thread is torn
// down instead of leaking until process exit.
readonly release: (cwd: string) => Effect.Effect<void>
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Search") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem/Search") {}
export const use = serviceUse(Service)
function key(dir: string) {
return Buffer.from(dir).toString("base64url")
}
function fffSync<A>(action: string, run: () => A) {
return Effect.try({
try: run,
catch: (cause) => new Error(`fff ${action} failed`, { cause }),
})
}
function normalize(text: string) {
return text.replaceAll("\\", "/")
}
// fff supports glob narrowing for any search out of the box
function fffGlobbedQuery(query: string, glob?: string | string[]) {
if (query && glob) {
const resolvedGlob = Array.isArray(glob) ? glob.join(" ") : glob
return `${resolvedGlob} ${query}`
}
return query ?? glob
}
function remember(state: State, dir: string, text: string, files: string[]) {
if (!files.length) return
const next = Array.from(new Set(files.map(FSUtil.resolve))).slice(0, 64)
if (!next.length) return
const idx = state.recent.findIndex((item) => item.dir === dir && item.text === text)
if (idx >= 0) state.recent.splice(idx, 1)
state.recent.unshift({ dir, text, files: next })
if (state.recent.length > 32) state.recent.length = 32
}
function item(hit: Fff.Hit): Item {
const line = Buffer.from(hit.lineContent)
return {
path: { text: normalize(hit.relativePath) },
lines: { text: hit.lineContent },
line_number: hit.lineNumber,
absolute_offset: hit.byteOffset,
submatches: hit.matchRanges
.map(([start, end]) => {
const text = line.subarray(start, end).toString("utf8")
if (!text) return undefined
return {
match: { text },
start,
end,
}
})
.filter((row): row is Item["submatches"][number] => Boolean(row)),
}
}
function collectPaths<T>(
out: { items: T[]; scores: Array<{ total: number }> },
toPath: (item: T) => string,
opts?: { includeZeroScore?: boolean },
): string[] {
return Array.from(
new Set(
out.items.flatMap((item, idx): string[] => {
const score = out.scores[idx]
if (!score || (!opts?.includeZeroScore && score.total <= 0)) return []
const text = toPath(item)
if (!text) return []
return [text]
}),
),
)
}
function searchFff(
pick: Fff.Picker,
kind: "file" | "directory" | "all",
query: string,
opts: { currentFile?: string; pageIndex?: number; pageSize?: number },
): Fff.Result<string[]> {
if (kind === "directory") {
const out = pick.directorySearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
}
}
if (kind === "all") {
const out = pick.mixedSearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value, (entry) => normalize(entry.item.relativePath), { includeZeroScore: !query }),
}
}
const out = pick.fileSearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
}
}
export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service> = Layer.effect(
export const ripgrepLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const rg = yield* Ripgrep.Service
const state: State = {
pick: new Map<string, Picker>(),
wait: new Map<string, Deferred.Deferred<Picker, Error>>(),
recent: [] as Query[],
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const state = {
files: [] as string[],
directories: [] as string[],
}
yield* fs.ensureDir(root).pipe(Effect.ignore)
yield* Effect.addFinalizer(() =>
Effect.forEach(
state.pick.values(),
(entry) => fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore),
{ discard: true },
),
)
const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
const out = yield* rg.search(input)
return {
items: out.items,
partial: out.partial,
hasNextPage: false,
engine: "ripgrep" as const,
}
})
// Lazy, shared scan-wait for a picker. Preserves the original behavior: if
// the scan does not finish within the budget the picker is destroyed and
// dropped from the cache so callers fall back to ripgrep (and the next
// request recreates a fresh picker).
const scanReady = (dir: string, pick: Fff.Picker) =>
Effect.gen(function* () {
const scanned = yield* Effect.tryPromise({
try: () => pick.waitForScan(5_000),
catch: (cause) => new Error("fff waitForScan failed", { cause }),
})
if (!scanned.ok || !scanned.value) {
yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
state.pick.delete(dir)
log.warn("fff scan not ready", { dir })
return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error))
}
const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error })
const directories = new Set<string>()
yield* ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
state.files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
state.directories = Array.from(directories)
}),
})
// Create (or return) the picker for a directory. Creation is synchronous
// and does not await the scan; the native background scan starts as soon as
// the picker exists. The `wait` gate dedupes concurrent creation.
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
// The opencode test runtime owns an isolated XDG tree that Windows must
// remove before process exit, so use ripgrep instead of native FFF there.
if (process.env.OPENCODE_TEST_HOME) return undefined
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
Effect.catch((error) => {
log.warn("fff availability check failed", { error })
return Effect.succeed(false)
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
return Service.of({
glob: (input) =>
Effect.gen(function* () {
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.orDie)
const cwd = info.type === "File" ? path.dirname(target) : target
return yield* ripgrep
.glob({
cwd,
pattern: input.pattern,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
})
.pipe(
Effect.map((result) =>
result.map(
(entry) =>
new FileSystem.Entry({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
Effect.orDie,
)
}),
)
if (!available) return undefined
const dir = FSUtil.resolve(cwd)
const existing = state.pick.get(dir)
if (existing) return existing
const pending = state.wait.get(dir)
if (pending) return yield* Deferred.await(pending)
const gate = yield* Deferred.make<Picker, Error>()
state.wait.set(dir, gate)
return yield* Effect.gen(function* () {
const id = key(dir)
const isFirstPicker = state.pick.size === 0
const made = yield* fffSync("create picker", () =>
Fff.create({
basePath: dir,
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
historyDbPath: path.join(root, `${id}.history.mdb`),
// fff uses a bit different log version, also with spans so keep
// them in the same folder for debuggability
logFilePath: path.join(Global.Path.log, "fff.log"),
logLevel: Log.getLevel().toLowerCase() as Lowercase<Log.Level>,
aiMode: true,
// only the first toolcall picker can accumulate resources to index
// home directory, if the user specifically opened opencode at the
// $HOME level or asked it to search there on purpose, otherwise fallback
enableHomeDirScanning: isFirstPicker,
// on unix system it is 99.9% that you do not need to search for the
// content at the / so make fff fail creation and fallback to rg
enableFsRootScanning: isFirstPicker && process.platform === "win32",
}),
)
if (!made.ok) {
log.warn("fff init failed", { dir, error: made.error })
const err = new Error(made.error)
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
}
const pick = made.value
const entry: Picker = { pick, ready: yield* Effect.cached(scanReady(dir, pick)) }
state.pick.set(dir, entry)
yield* Deferred.succeed(gate, entry)
return entry
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
if (state.wait.get(dir) === gate) state.wait.delete(dir)
yield* Deferred.fail(gate, new Error("fff init interrupted")).pipe(Effect.ignore)
}),
),
)
})
// Resolve a usable, scanned picker for a directory, or undefined when fff is
// unavailable or the scan did not become ready.
const picker = Effect.fn("Search.picker")(function* (cwd: string) {
const entry = yield* acquire(cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
if (!entry) return undefined
const ready = yield* entry.ready.pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
if (!ready) return undefined
return entry.pick
})
const files: Interface["files"] = (input) => rg.files(input)
const tree: Interface["tree"] = (input) => rg.tree(input)
// in 99% of use cases user that is opened opencode at certain directory will
// conduct a file search in this direcotry, it could be switched later but
// mostly always we will need a file picker for cwd
// so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
const warm: Interface["warm"] = Effect.fn("Search.warm")(function* (cwd) {
yield* acquire(cwd).pipe(Effect.ignore)
})
// Tear down the picker for a directory. fff pickers own a native background
// watcher thread that otherwise lives until the runtime scope closes (i.e.
// process exit), so disposing the instance that warmed it must destroy it
// here or the thread leaks against a directory that may already be gone.
const release: Interface["release"] = Effect.fn("Search.release")(function* (cwd) {
const dir = FSUtil.resolve(cwd)
const pending = state.wait.get(dir)
if (pending) {
state.wait.delete(dir)
yield* Deferred.fail(pending, new Error("fff picker released")).pipe(Effect.ignore)
}
const entry = state.pick.get(dir)
if (entry) {
state.pick.delete(dir)
yield* fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore)
}
const remaining = state.recent.filter((item) => item.dir !== dir)
state.recent.splice(0, state.recent.length, ...remaining)
})
const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
const query = input.query.trim()
const kind = input.kind ?? "file"
const pick = yield* picker(input.cwd)
if (!pick) return undefined
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const fffResult = yield* fffSync(`${kind} search`, () =>
searchFff(pick, kind, query, {
pageIndex: 0,
currentFile: input.current, // supports both relative and absolute (relative preferred)
pageSize: limit,
grep: (input) =>
Effect.gen(function* () {
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.orDie)
const cwd = info.type === "File" ? path.dirname(target) : target
return yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: info.type === "File" ? path.basename(target) : undefined,
include: input.include,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
})
.pipe(
Effect.map((result) =>
result.map(
(match) =>
new FileSystem.Match({
...match,
entry: new FileSystem.Entry({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
}),
}),
),
),
Effect.orDie,
)
}),
).pipe(
Effect.catch((error) => {
log.warn(`fff ${kind} search failed`, { dir, query, error })
return Effect.succeed<Fff.Result<string[]> | undefined>(undefined)
find: (input) =>
Effect.gen(function* () {
const items =
input.type === "file"
? state.files
: input.type === "directory"
? state.directories
: [...state.files, ...state.directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative
const absolute = path.resolve(location.directory, clean)
return new FileSystem.Entry({
path: RelativePath.make(relative),
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
})
})
}),
)
if (!fffResult) return undefined
if (!fffResult.ok) {
log.warn(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
return undefined
}
const rows = fffResult.value
remember(
state,
dir,
query,
rows.map((row) => path.join(dir, row)),
)
return rows.slice(0, limit)
})
const search: Interface["search"] = Effect.fn("Search.search")(function* (input) {
input.signal?.throwIfAborted()
if (input.file?.length) return yield* rip(input)
const pick = yield* picker(input.cwd)
if (!pick) return yield* rip(input)
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const fffGrep = yield* fffSync("grep", () =>
pick.grep(fffGlobbedQuery(input.pattern, input.glob), {
mode: "regex",
pageSize: limit,
timeBudgetMs: 1_500,
}),
).pipe(
Effect.catch((error) => {
log.warn("fff grep failed", { dir, pattern: input.pattern, error })
return Effect.succeed<Fff.Result<Fff.Grep> | undefined>(undefined)
}),
)
if (!fffGrep) return yield* rip(input)
if (!fffGrep.ok) {
log.warn("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
return yield* rip(input)
}
const rows: Item[] = fffGrep.value.items.map(item)
const regexFallbackError = fffGrep.value.regexFallbackError
remember(state, dir, input.pattern, Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))))
return {
items: rows,
partial: false,
hasNextPage: !!fffGrep.value.nextCursor,
engine: "fff" as const,
regexFallbackError,
}
})
const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
input.signal?.throwIfAborted()
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const pick = yield* picker(dir)
if (pick) {
const fffGlob = yield* fffSync("glob file search", () =>
pick.glob(normalize(input.pattern), {
pageIndex: 0,
pageSize: limit,
}),
).pipe(
Effect.catch((error) => {
log.warn("fff glob failed", { dir, pattern: input.pattern, error })
return Effect.succeed<Fff.Result<Fff.Search> | undefined>(undefined)
}),
)
if (fffGlob?.ok) {
const rows: string[] = Array.from(new Set(fffGlob.value.items.map((item) => normalize(item.relativePath))))
remember(
state,
dir,
input.pattern,
rows.map((row) => path.join(dir, row)),
)
return {
files: rows.slice(0, limit).map((row) => path.join(dir, row)),
truncated: fffGlob.value.totalMatched > rows.length,
}
} else if (fffGlob) {
log.warn("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
// fall through to the fallback
}
}
const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe(
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > limit
if (truncated) rows.length = limit
const output = yield* Effect.forEach(
rows,
Effect.fnUntraced(function* (file) {
const full = path.join(dir, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const time =
info?.mtime.pipe(
Option.map((item) => item.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { file: full, time }
}),
{ concurrency: 16 },
)
output.sort((a, b) => b.time - a.time)
return {
files: output.map((item) => item.file),
truncated,
}
})
const open: Interface["open"] = Effect.fn("Search.open")(function* (input) {
const file = input.cwd
? FSUtil.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
: FSUtil.resolve(input.file)
const idx = state.recent.findIndex((item) => item.files.includes(file))
if (idx < 0) return
const row = state.recent[idx]
state.recent.splice(idx, 1)
const entry = state.pick.get(row.dir)
if (!entry) return
const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe(
Effect.catch((error) => {
log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error })
return Effect.succeed<Fff.Result<boolean> | undefined>(undefined)
}),
)
if (!out) return
if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
})
return Service.of({ files, tree, search, file, glob, open, warm, release })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
export const fffLayer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const result = yield* Effect.try({
try: () =>
Fff.create({
basePath: location.directory,
aiMode: true,
enableFsRootScanning: true,
enableHomeDirScanning: true,
}),
catch: (cause) => cause,
}).pipe(Effect.orDie)
if (!result.ok) return yield* Effect.die(result.error)
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
const scanned = yield* Effect.tryPromise({
try: () => result.value.waitForScan(5_000),
catch: (cause) => cause,
}).pipe(Effect.orDie)
if (!scanned.ok || !scanned.value) return yield* Effect.die(scanned.ok ? "fff scan timed out" : scanned.error)
return Service.of({
glob: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
pageIndex: 0,
pageSize: input.limit,
})
if (!found.ok) throw found.error
return found.value.items.map((item) => {
const absolute = path.resolve(location.directory, item.relativePath)
return new FileSystem.Entry({
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
type: "file",
mime: FSUtil.mimeType(absolute),
})
})
}),
grep: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.grep(
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
.filter((value) => value !== undefined)
.join(" "),
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
)
if (!found.ok) throw found.error
return found.value.items.map((match) => {
const bytes = Buffer.from(match.lineContent)
return new FileSystem.Match({
entry: new FileSystem.Entry({
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
type: "file",
mime: FSUtil.mimeType(match.relativePath),
}),
line: match.lineNumber,
offset: match.byteOffset,
text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
submatches: match.matchRanges.map(([start, end]) => ({
text: bytes.subarray(start, end).toString("utf8"),
start,
end,
})),
})
})
}),
find: (input) =>
Effect.sync(() => {
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
const items = (() => {
if (input.type === "file") {
const found = result.value.fileSearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
type: "file" as const,
score: found.value.scores[index]?.total ?? 0,
}))
}
if (input.type === "directory") {
const found = result.value.directorySearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
type: "directory" as const,
score: found.value.scores[index]?.total ?? 0,
}))
}
const found = result.value.mixedSearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.item.relativePath,
type: item.type,
score: found.value.scores[index]?.total ?? 0,
}))
})()
return items
.sort((a, b) => b.score - a.score || a.path.length - b.path.length)
.map((item) => {
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
const absolute = path.resolve(location.directory, relative)
return new FileSystem.Entry({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
})
})
}),
})
}),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export function tree(input: Ripgrep.TreeInput) {
return runPromise((svc) => svc.tree(input))
}
export function search(input: Ripgrep.SearchInput) {
return runPromise((svc) => svc.search(input))
}
export function file(input: FileInput) {
return runPromise((svc) => svc.file(input))
}
export function glob(input: GlobInput) {
return runPromise((svc) => svc.glob(input))
}
export function open(input: { cwd?: string; file: string }) {
return runPromise((svc) => svc.open(input))
}
export * as Search from "./search"
export const defaultLayer = Layer.unwrap(Effect.sync(() => (Fff.available() ? fffLayer : ripgrepLayer)))
+10 -10
View File
@@ -12,13 +12,11 @@ import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { lazy } from "../util/lazy"
import * as Log from "../util/log"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
@@ -38,8 +36,7 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
} catch {
return
}
})
@@ -71,14 +68,17 @@ export const layer = Layer.effect(
const backend = getBackend()
const location = yield* Location.Service
if (!backend) {
log.error("watcher backend not supported", { directory: location.directory, platform: process.platform })
yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
return Service.of({})
}
const w = watcher()
if (!w) return Service.of({})
log.info("watcher backend", { directory: location.directory, platform: process.platform, backend })
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
@@ -103,9 +103,8 @@ export const layer = Layer.effect(
Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) })
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.void
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) })
}),
)
}
@@ -133,8 +132,9 @@ export const layer = Layer.effect(
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(Service.of({}))
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
)
}),
),
)
-1
View File
@@ -46,7 +46,6 @@ export const Flag = {
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("OPENCODE_EXPERIMENTAL_SESSION_SWITCHER"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+3
View File
@@ -7,6 +7,8 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use"
import { LayerNode } from "./effect/layer-node"
import { filesystem } from "./effect/layer-node-platform"
export namespace FSUtil {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
@@ -194,6 +196,7 @@ export namespace FSUtil {
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
export const node = LayerNode.make(layer, [filesystem])
// Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string {
+2
View File
@@ -6,6 +6,7 @@ import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util"
import { AppProcess } from "./process"
import { LayerNode } from "./effect/layer-node"
export interface Repo {
/**
@@ -400,6 +401,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
export interface Result {
readonly exitCode: number
+2
View File
@@ -5,6 +5,7 @@ import os from "os"
import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock"
import { Flag } from "./flag/flag"
import { LayerNode } from "./effect/layer-node"
const app = "opencode"
const data = path.join(xdgData!, app)
@@ -76,6 +77,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer
export const node = LayerNode.make(layer, [])
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
+9 -3
View File
@@ -34,8 +34,11 @@ export class SizeError extends Schema.TaggedErrorClass<SizeError>()("Image.SizeE
export interface Interface {
readonly normalize: (
resource: string,
content: FileSystem.BinaryContent,
) => Effect.Effect<FileSystem.BinaryContent, ResizerUnavailableError | DecodeError | SizeError>
content: FileSystem.Content & { readonly encoding: "base64" },
) => Effect.Effect<
FileSystem.Content & { readonly encoding: "base64" },
ResizerUnavailableError | DecodeError | SizeError
>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
@@ -50,7 +53,10 @@ export const layer = Layer.effect(
catch: () => new ResizerUnavailableError(),
}).pipe(Effect.flatMap((adapter) => adapter.make)),
)
const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) {
const normalize = Effect.fn("Image.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
) {
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
+2 -2
View File
@@ -19,7 +19,7 @@ export const make = Effect.gen(function* () {
)
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.BinaryContent,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: {
readonly autoResize: boolean
readonly maxWidth: number
@@ -72,7 +72,7 @@ export const make = Effect.gen(function* () {
for (const [mime, encode] of encoders) {
const candidate = Buffer.from(encode()).toString("base64")
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime })
return { ...content, content: candidate, encoding: "base64" as const, mime }
}
} finally {
resized.free()
+7 -18
View File
@@ -18,11 +18,11 @@ import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { FileSystem } from "./filesystem"
import { Ripgrep } from "./ripgrep"
import { Watcher } from "./filesystem/watcher"
import { LocationMutation } from "./location-mutation"
import { LocationSearch } from "./location-search"
import { FileMutation } from "./file-mutation"
import { ProjectReference } from "./project-reference"
import { Reference } from "./reference"
import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
@@ -33,7 +33,6 @@ import { ToolRegistry } from "./tool/registry"
import { ApplicationTools } from "./tool/application-tools"
import { ToolOutputStore } from "./tool-output-store"
import { AppProcess } from "./process"
import { Ripgrep } from "./ripgrep"
import { SessionStore } from "./session/store"
import { SessionTodo } from "./session/todo"
import { QuestionV2 } from "./question"
@@ -52,7 +51,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
location,
Policy.locationLayer,
Config.locationLayer,
ProjectReference.locationLayer,
Reference.locationLayer,
PluginV2.locationLayer,
Catalog.locationLayer,
CommandV2.locationLayer,
@@ -74,14 +73,12 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
const services = Layer.mergeAll(base, resources, permissionsAndTools)
const image = Image.layer.pipe(Layer.provide(services))
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
const todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe(
Layer.provide(services),
Layer.provide(mutation),
Layer.provide(searches),
Layer.provide(resources),
Layer.provide(todos),
Layer.provide(questions),
@@ -93,18 +90,9 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(model),
Layer.provide(skillGuidance),
)
return Layer.mergeAll(
services,
image,
mutation,
searches,
resources,
todos,
questions,
model,
runner,
builtInTools,
).pipe(Layer.fresh)
return Layer.mergeAll(services, image, mutation, resources, todos, questions, model, runner, builtInTools).pipe(
Layer.fresh,
)
},
idleTimeToLive: "60 minutes",
dependencies: [
@@ -116,6 +104,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
FSUtil.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
PermissionSaved.defaultLayer,
-190
View File
@@ -1,190 +0,0 @@
export * as LocationSearch from "./location-search"
import path from "path"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "./filesystem"
import { FSUtil } from "./fs-util"
import { Ripgrep } from "./ripgrep"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
/**
* Location-scoped raw search substrate. Search authority is selected only by
* FileSystem, preserving Location-relative paths and named read
* references. Model formatting, leaf-tool permissions, and HTTP transport stay
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
* share the same bounded filesystem behavior.
*
* TODO: Expose this substrate through HTTP fs.search/fs.grep endpoints.
* TODO: Reuse this substrate for instruction and skill discovery where suitable.
*/
export const DEFAULT_RESULT_LIMIT = 100
export const MAX_RESULT_LIMIT = 100
export const MAX_LINE_PREVIEW_LENGTH = 2_000
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
export const FilesInput = Schema.Struct({
pattern: Schema.String,
...FileSystem.ListInput.fields,
limit: ResultLimit.pipe(Schema.optional),
})
export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
export const GrepInput = Schema.Struct({
pattern: Schema.String,
include: Schema.String.pipe(Schema.optional),
...FileSystem.ListInput.fields,
limit: ResultLimit.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
export class File extends Schema.Class<File>("LocationSearch.File")({
path: RelativePath,
canonical: Schema.String,
resource: Schema.String,
mtime: Schema.Number,
}) {}
export class Submatch extends Schema.Class<Submatch>("LocationSearch.Submatch")({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}) {}
export class Match extends Schema.Class<Match>("LocationSearch.Match")({
path: RelativePath,
canonical: Schema.String,
resource: Schema.String,
lines: Schema.String,
linePreviewTruncated: Schema.Boolean,
line: PositiveInt,
offset: NonNegativeInt,
submatches: Schema.Array(Submatch),
mtime: Schema.Number,
}) {}
export class FilesResult extends Schema.Class<FilesResult>("LocationSearch.FilesResult")({
items: Schema.Array(File),
truncated: Schema.Boolean,
partial: Schema.Boolean,
}) {}
export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepResult")({
items: Schema.Array(Match),
truncated: Schema.Boolean,
partial: Schema.Boolean,
}) {}
export interface Interface {
readonly files: (input: FilesInput) => Effect.Effect<FilesResult, Ripgrep.Error>
readonly grep: (input: GrepInput) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
const slash = (value: string) => value.replaceAll("\\", "/")
const cap = (limit?: number) => Math.min(limit ?? DEFAULT_RESULT_LIMIT, MAX_RESULT_LIMIT)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const ripgrep = yield* Ripgrep.Service
const candidate = Effect.fnUntraced(function* (root: FileSystem.RootTarget, cwd: string, value: string) {
const absolute = path.resolve(cwd, value)
const lexicallyContained =
root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real
if (!lexicallyContained) return
const canonical = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!canonical || !FSUtil.contains(root.root, canonical)) return
const info = yield* fs.stat(canonical).pipe(Effect.catch(() => Effect.void))
if (!info || info.type !== "File") return
const relative = slash(path.relative(root.root, canonical))
return {
path: RelativePath.make(relative),
canonical,
resource: root.reference === undefined ? relative : `${root.reference}:${relative}`,
mtime: info.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
),
}
})
return Service.of({
files: Effect.fn("LocationSearch.files")(function* (input) {
const root = yield* filesystem.resolveRoot(input)
if (root.type !== "directory")
return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
const result = yield* ripgrep.files({
cwd: root.real,
pattern: input.pattern,
limit: cap(input.limit),
signal: input.signal,
})
const mapped = yield* Effect.forEach(result.items, (item) => candidate(root, root.real, item), {
concurrency: 16,
})
const items = mapped.filter((item): item is File => item !== undefined).map((item) => new File(item))
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
return new FilesResult({
items,
truncated: result.truncated,
partial: result.partial || items.length !== result.items.length,
})
}),
grep: Effect.fn("LocationSearch.grep")(function* (input) {
const root = yield* filesystem.resolveRoot(input)
const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
const result = yield* ripgrep.grep({
cwd,
pattern: input.pattern,
include: input.include,
file: root.type === "file" ? path.basename(root.real) : undefined,
limit: cap(input.limit),
signal: input.signal,
})
const candidates = new Map<string, ReturnType<typeof candidate>>()
for (const item of result.items) {
if (!candidates.has(item.path.text)) {
candidates.set(item.path.text, yield* Effect.cached(candidate(root, cwd, item.path.text)))
}
}
const mapped = yield* Effect.forEach(
result.items,
(item) =>
candidates.get(item.path.text)!.pipe(
Effect.map(
(file) =>
file &&
new Match({
...file,
lines: item.lines.text.slice(0, MAX_LINE_PREVIEW_LENGTH),
linePreviewTruncated: item.lines.text.length > MAX_LINE_PREVIEW_LENGTH,
line: item.line_number,
offset: item.absolute_offset,
submatches: item.submatches.map(
(submatch) =>
new Submatch({ text: submatch.match.text, start: submatch.start, end: submatch.end }),
),
}),
),
),
{ concurrency: 16 },
)
const items = mapped.filter((item): item is Match => item !== undefined)
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
return new GrepResult({
items,
truncated: result.truncated,
partial: result.partial || items.length !== result.items.length,
})
}),
})
}),
)
+5 -4
View File
@@ -8,6 +8,8 @@ import { Hash } from "./util/hash"
import { FSUtil } from "./fs-util"
import { InstallationChannel, InstallationVersion } from "./installation/version"
import { EventV2 } from "./event"
import { LayerNode } from "./effect/layer-node"
import { httpClient } from "./effect/layer-node-platform"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -54,7 +56,7 @@ export const Model = Schema.Struct({
Schema.Union([
Schema.Literal(true),
Schema.Struct({
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
@@ -227,9 +229,7 @@ export const layer = Layer.effect(
yield* events.publish(Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) =>
Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
})
@@ -248,5 +248,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient])
export * as ModelsDev from "./models-dev"
+3
View File
@@ -7,6 +7,8 @@ import { NodeFileSystem } from "@effect/platform-node"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { EffectFlock } from "./util/effect-flock"
import { LayerNode } from "./effect/layer-node"
import { filesystem } from "./effect/layer-node-platform"
import { makeRuntime } from "./effect/runtime"
import { NpmConfig } from "./npm-config"
@@ -250,6 +252,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node])
const { runPromise } = makeRuntime(Service, defaultLayer)
+21
View File
@@ -0,0 +1,21 @@
export * as Observability from "./observability"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Logger, References } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpSerialization } from "effect/unstable/observability"
import { Logging } from "./observability/logging"
import { Otlp } from "./observability/otlp"
export const layer = Layer.unwrap(
Effect.gen(function* () {
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
}),
)
@@ -0,0 +1,71 @@
import { Formatter, Logger, type LogLevel } from "effect"
import path from "path"
import { Global } from "../global"
import { runID } from "./shared"
function formatter(id: string = runID) {
return Logger.map(Logger.formatStructured, (output) => {
const messages = Array.isArray(output.message) ? output.message : [output.message]
return [
["timestamp", output.timestamp],
["level", output.level],
["run", id],
...messages.flatMap((value) => (plain(value) ? flatten(value) : [["message", value] as const])),
...(output.cause === undefined ? [] : [["cause", output.cause] as const]),
...flatten(output.spans),
...flatten(output.annotations),
]
.map(([key, value]) => `${key}=${format(value)}`)
.join(" ")
})
}
function flatten(
input: Record<string, unknown>,
prefix = "",
seen = new WeakSet<object>(),
): Array<readonly [string, unknown]> {
if (seen.has(input)) return [[prefix, "[Circular]"]]
seen.add(input)
const entries = Object.entries(input)
if (entries.length === 0 && prefix) return [[prefix, input]]
return entries.flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key
return plain(value) ? flatten(value, path, seen) : [[path, value] as const]
})
}
function plain(input: unknown): input is Record<string, unknown> {
if (input === null || typeof input !== "object" || Array.isArray(input)) return false
const prototype = Object.getPrototypeOf(input)
return prototype === Object.prototype || prototype === null
}
function format(input: unknown) {
const value = typeof input === "string" ? input : Formatter.format(input)
return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
}
export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) {
// Do not set batchWindow to 0; it causes high idle CPU usage.
return Logger.toFile(formatter(id), file, { flag: "a" })
}
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
export function minimumLogLevel() {
const value = process.env.OPENCODE_LOG_LEVEL?.toUpperCase()
const levels = {
DEBUG: "Debug",
INFO: "Info",
WARN: "Warn",
ERROR: "Error",
} as const satisfies Record<string, LogLevel.LogLevel>
return value && value in levels ? levels[value as keyof typeof levels] : levels.INFO
}
export function loggers() {
return process.env.OPENCODE_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()]
}
export * as Logging from "./logging"
+79
View File
@@ -0,0 +1,79 @@
import { Layer } from "effect"
import { OtlpLogger } from "effect/unstable/observability"
import { Flag } from "../flag/flag"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { runID } from "./shared"
const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, entry) => {
const [key, ...value] = entry.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
function resourceAttributes() {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
}
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...resourceAttributes(),
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.run": runID,
"service.instance.id": runID,
},
}
}
export function loggers() {
if (!endpoint) return []
return [OtlpLogger.make({ url: `${endpoint}/v1/logs`, resource: resource(), headers })]
}
export async function tracingLayer() {
if (!endpoint) return Layer.empty
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
const manager = new AsyncLocalStorageContextManager()
manager.enable()
context.setGlobalContextManager(manager)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
headers,
}),
),
}))
}
export * as Otlp from "./otlp"
@@ -0,0 +1 @@
export const runID = crypto.randomUUID().slice(0, 8)
+7
View File
@@ -9,6 +9,7 @@ import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
@@ -25,6 +26,7 @@ import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
import { Reference } from "../reference"
type Plugin = {
id: PluginV2.ID
@@ -42,6 +44,7 @@ type Plugin = {
| Config.Service
| ModelsDev.Service
| SkillV2.Service
| Reference.Service
>
}
@@ -67,6 +70,7 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const skill = yield* SkillV2.Service
const references = yield* Reference.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
@@ -85,6 +89,7 @@ export const layer = Layer.effect(
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Global.Service, global),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, references),
Effect.provideService(PluginV2.Service, plugin),
),
})
@@ -104,6 +109,7 @@ export const layer = Layer.effect(
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigReferencePlugin.Plugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
@@ -124,4 +130,5 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),
Layer.provideMerge(Reference.locationLayer),
)
+2
View File
@@ -3,6 +3,7 @@ import type { PlatformError } from "effect/PlatformError"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
import { LayerNode } from "./effect/layer-node"
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
command: Schema.String,
@@ -230,5 +231,6 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
export const node = LayerNode.make(layer, [CrossSpawnSpawner.node])
export * as AppProcess from "./process"
-241
View File
@@ -1,241 +0,0 @@
export * as ProjectReference from "./project-reference"
import path from "path"
import { Context, Effect, Layer } from "effect"
import { Config } from "./config"
import { ConfigReference } from "./config/reference"
import { FSUtil } from "./fs-util"
import { Flag } from "./flag/flag"
import { Global } from "./global"
import { Location } from "./location"
import { Repository } from "./repository"
import { RepositoryCache } from "./repository-cache"
export type Resolved =
| { readonly name: string; readonly kind: "local"; readonly path: string }
| {
readonly name: string
readonly kind: "git"
readonly repository: string
readonly reference: Repository.RemoteReference
readonly path: string
readonly branch?: string
}
| { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string }
type Valid = Exclude<Resolved, { kind: "invalid" }>
export type Mention =
| {
readonly name: string
readonly kind: "reference"
readonly reference: Valid
readonly target?: string
readonly path: string
}
| { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string }
| {
readonly name: string
readonly kind: "missing"
readonly target: string
readonly path: string
readonly message: string
}
export interface Interface {
readonly list: () => Effect.Effect<Resolved[]>
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
readonly resolveMention: (value: string) => Effect.Effect<Mention | undefined, RepositoryCache.Error>
readonly ensurePath: (target?: string) => Effect.Effect<void, RepositoryCache.Error>
readonly containsManagedPath: (target?: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectReference") {}
type Materializer = {
readonly name: string
readonly repository: string
readonly path: string
readonly run: Effect.Effect<void, RepositoryCache.Error>
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const cache = yield* RepositoryCache.Service
const references = resolveAll({
references: ConfigReference.normalize(
Object.assign(
{},
...(yield* config.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.map((document) => document.info.references ?? {}),
),
),
directory: location.project.directory,
home: global.home,
repos: global.repos,
})
const materializers = yield* Effect.forEach(
uniqueGitReferences(references),
Effect.fnUntraced(function* (reference) {
return {
name: reference.name,
repository: reference.repository,
path: reference.path,
run: yield* Effect.cached(
cache
.ensure({ reference: reference.reference, branch: reference.branch, refresh: true })
.pipe(Effect.asVoid),
),
}
}),
)
yield* Effect.forEach(
materializers,
(materializer) =>
materializer.run.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize project reference").pipe(
Effect.annotateLogs({ name: materializer.name, repository: materializer.repository, cause }),
),
),
),
{ concurrency: 4, discard: true },
).pipe(Effect.forkScoped)
const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) {
const normalized = normalizePath(target)
if (!normalized)
return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true })
yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void
})
return Service.of({
list: Effect.fn("ProjectReference.list")(function* () {
return references
}),
get: Effect.fn("ProjectReference.get")(function* (name: string) {
return references.find((reference) => reference.name === name)
}),
ensurePath,
containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) {
const normalized = normalizePath(target)
return normalized
? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized))
: false
}),
resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) {
const [name, ...rest] = value.split("/")
const target = rest.length ? rest.join("/") : undefined
const reference = references.find((reference) => reference.name === name)
if (!reference) return
if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message }
if (reference.kind === "git") yield* ensurePath(reference.path)
if (!target) return { name, kind: "reference", reference, path: reference.path }
const resolved = path.resolve(reference.path, target)
if (!FSUtil.contains(reference.path, resolved))
return { name, kind: "invalid", target, message: "Reference target escapes its root" }
if (!(yield* fs.existsSafe(resolved)))
return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
return { name, kind: "reference", reference, target, path: resolved }
}),
})
}),
)
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
const inert: Interface = {
list: () => Effect.succeed([]),
get: () => Effect.succeed(undefined),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
}
export function resolveAll(input: {
references: ConfigReference.NormalizedInfo
directory: string
home: string
repos: string
}) {
const seen = new Map<string, { name: string; branch?: string }>()
return Object.entries(input.references).map(([name, reference]): Resolved => {
const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos })
if (resolved.kind !== "git") return resolved
const existing = seen.get(resolved.path)
if (!existing) {
seen.set(resolved.path, { name, branch: resolved.branch })
return resolved
}
if (existing.branch === resolved.branch) return resolved
return {
name,
kind: "invalid",
repository: resolved.repository,
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`,
}
})
}
export function resolve(input: {
name: string
reference: ConfigReference.NormalizedEntry
directory: string
home: string
repos: string
}): Resolved {
if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message }
if (input.reference.kind === "local") {
return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) }
}
const reference = Repository.parse(input.reference.repository)
if (!reference || !Repository.isRemote(reference)) {
return {
name: input.name,
kind: "invalid",
repository: input.reference.repository,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
}
}
return {
name: input.name,
kind: "git",
repository: input.reference.repository,
reference,
path: Repository.cachePath(input.repos, reference),
branch: input.reference.branch,
}
}
function localPath(directory: string, home: string, value: string) {
if (value.startsWith("~/")) return path.join(home, value.slice(2))
return path.isAbsolute(value) ? value : path.resolve(directory, value)
}
function uniqueGitReferences(references: Resolved[]) {
const seen = new Set<string>()
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git" || seen.has(reference.path)) return false
seen.add(reference.path)
return true
})
}
function normalizePath(target?: string) {
if (!target) return
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
}
function contains(parent: string, child: string) {
return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
}
+10 -3
View File
@@ -8,6 +8,7 @@ import { AbsolutePath, withStatics } from "./schema"
import { FSUtil } from "./fs-util"
import { Database } from "./database/database"
import { Git } from "./git"
import { LayerNode } from "./effect/layer-node"
import { Hash } from "./util/hash"
import { ProjectDirectoryTable } from "./project/sql"
@@ -36,7 +37,12 @@ export const DirectoriesInput = Schema.Struct({
}).annotate({ identifier: "Project.DirectoriesInput" })
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = Schema.Array(AbsolutePath).annotate({ identifier: "Project.Directories" })
export const Directories = Schema.Array(
Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["main", "root", "git_worktree"]),
}),
).annotate({ identifier: "Project.Directories" })
export type Directories = typeof Directories.Type
export interface Interface {
@@ -73,13 +79,13 @@ export const layer = Layer.effect(
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
const rows = yield* db
.select({ directory: ProjectDirectoryTable.directory })
.select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => AbsolutePath.make(row.directory))
return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type }))
})
const cached = Effect.fnUntraced(function* (dir: string) {
@@ -154,3 +160,4 @@ export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Git.defaultLayer),
)
export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node])
+2
View File
@@ -8,6 +8,7 @@ import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LayerNode } from "../effect/layer-node"
import { Project } from "../project"
import { ProjectDirectoryTable } from "./sql"
import { makeStrategies } from "./copy-strategies"
@@ -275,3 +276,4 @@ export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node])
+4 -7
View File
@@ -7,9 +7,7 @@ import { Location } from "./location"
import { NonNegativeInt, PositiveInt } from "./schema"
import { PtyID } from "./pty/schema"
import { lazy } from "./util/lazy"
import * as Log from "./util/log"
const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
@@ -158,7 +156,7 @@ export const layer = Layer.effect(
const session = sessions.get(id)
if (!session) return false
sessions.delete(id)
log.info("removing session", { id })
yield* Effect.logInfo("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
return true
@@ -179,7 +177,7 @@ export const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
const id = PtyID.ascending()
log.info("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() =>
spawn(input.command, input.args, {
@@ -231,7 +229,7 @@ export const layer = Layer.effect(
if (session.info.status === "exited") return
runFork(
Effect.gen(function* () {
log.info("session exited", { id, exitCode })
yield* Effect.logInfo("session exited", { id, exitCode })
session.info.status = "exited"
yield* events.publish(Event.Exited, { id, exitCode })
yield* removeSession(id)
@@ -263,7 +261,7 @@ export const layer = Layer.effect(
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
log.info("client connected to session", { id, directory: location.directory })
yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
const sub = sock(ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
@@ -299,7 +297,6 @@ export const layer = Layer.effect(
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
cleanup()
},
}
+2
View File
@@ -4,6 +4,7 @@ import { WorkspaceV2 } from "../workspace"
import { PositiveInt } from "../schema"
import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
import { LayerNode } from "../effect/layer-node"
const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000
@@ -56,3 +57,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
export const layer = Layer.effect(Service, make())
export const defaultLayer = layer
export const node = LayerNode.make(layer, [])
+2 -3
View File
@@ -32,7 +32,8 @@ class SessionModelValidation extends Context.Service<
}
>()("@opencode/public/OpenCode/SessionModelValidation") {}
const LocationServicesLayer = LocationServiceMap.layer
const ApplicationToolsLayer = ApplicationTools.layer
const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
const SessionModelValidationLayer = Layer.effect(
SessionModelValidation,
Effect.gen(function* () {
@@ -78,8 +79,6 @@ const SessionsLayer = Layer.merge(
),
SessionModelValidationLayer,
).pipe(Layer.provide(LocationServicesLayer))
const ApplicationToolsLayer = ApplicationTools.layer
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect(
Service,
+114
View File
@@ -0,0 +1,114 @@
export * as Reference from "./reference"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft } from "immer"
import { Global } from "./global"
import { EventV2 } from "./event"
import { Repository } from "./repository"
import { RepositoryCache } from "./repository-cache"
import { AbsolutePath } from "./schema"
import { State } from "./state"
export class Info extends Schema.Class<Info>("Reference.Info")({
name: Schema.String,
path: AbsolutePath,
source: Schema.suspend(() => Source),
}) {}
export class LocalSource extends Schema.Class<LocalSource>("Reference.LocalSource")({
type: Schema.Literal("local"),
path: AbsolutePath,
}) {}
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
type: Schema.Literal("git"),
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
}) {}
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
export type Source = typeof Source.Type
export const Event = {
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
}
type Data = {
sources: Map<string, Source>
}
type Editor = {
add(name: string, source: Source): void
remove(name: string): void
list(): readonly [string, Source][]
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Reference") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const events = yield* EventV2.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const materialized = new Map<string, Info>()
const state = State.create<Data, Editor>({
initial: () => ({ sources: new Map() }),
editor: (draft) => ({
add: (name, source) => draft.sources.set(name, castDraft(source)),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
finalize: (editor) =>
Effect.gen(function* () {
materialized.clear()
const seen = new Map<string, string | undefined>()
for (const [name, source] of editor.list()) {
if (source.type === "local") {
materialized.set(name, new Info({ name, path: source.path, source }))
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
const target = Repository.cachePath(global.repos, repository)
if (seen.has(target) && seen.get(target) !== source.branch) continue
seen.set(target, source.branch)
materialized.set(name, new Info({ name, path: AbsolutePath.make(target), source }))
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* events.publish(Event.Updated, {})
}),
})
return Service.of({
transform: state.transform,
list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values())
}),
})
}),
)
export const locationLayer = layer
+125 -30
View File
@@ -2,20 +2,23 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Ripgrep as FileSystemRipgrep } from "./filesystem/ripgrep"
import path from "path"
import { Entry, Match } from "./filesystem/schema"
import { FSUtil } from "./fs-util"
import { AppProcess, collectStream, waitForAbort } from "./process"
import { NonNegativeInt, PositiveInt } from "./schema"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary"
/**
* Small core-owned ripgrep execution adapter. It deliberately exposes raw
* process-oriented rows, not model text or permission behavior. LocationSearch
* supplies read authority and bounded substrate results; future leaf tools own
* process-oriented rows, not model text or permission behavior. Search maps
* these rows into filesystem results; leaf tools own
* presentation and permission prompts.
*/
const ERROR_BYTES = 8 * 1024
export const MAX_RECORD_BYTES = 64 * 1024
export const MAX_SUBMATCHES = 100
const MAX_RECORD_BYTES = 64 * 1024
const MAX_SUBMATCHES = 100
const RawMatch = Schema.Struct({
type: Schema.Literal("match"),
@@ -34,7 +37,7 @@ const RawMatch = Schema.Struct({
}),
})
export type Match = (typeof RawMatch.Type)["data"]
type RawMatchData = (typeof RawMatch.Type)["data"]
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
message: Schema.String,
@@ -46,16 +49,22 @@ export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternE
message: Schema.String,
}) {}
export interface Result<A> {
readonly items: A[]
readonly truncated: boolean
readonly partial: boolean
}
export interface FilesInput {
export interface FindInput {
readonly cwd: string
readonly pattern: string
readonly limit: number
readonly hidden?: boolean
readonly follow?: boolean
readonly signal?: AbortSignal
readonly onEntry?: (entry: Entry) => Effect.Effect<void>
}
export interface GlobInput {
readonly cwd: string
readonly pattern: string
readonly limit: number
readonly hidden?: boolean
readonly follow?: boolean
readonly signal?: AbortSignal
}
@@ -69,8 +78,9 @@ export interface GrepInput {
}
export interface Interface {
readonly files: (input: FilesInput) => Effect.Effect<Result<string>, Error>
readonly grep: (input: GrepInput) => Effect.Effect<Result<Match>, Error | InvalidPatternError>
readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[], Error>
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[], Error | InvalidPatternError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
@@ -84,7 +94,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const process = yield* AppProcess.Service
const binary = yield* FileSystemRipgrep.Service
const binary = yield* RipgrepBinary.Service
const run = <A>(input: {
readonly cwd: string
@@ -93,6 +103,7 @@ export const layer = Layer.effect(
readonly signal?: AbortSignal
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
readonly pattern?: string
readonly onItem?: (item: A) => Effect.Effect<void>
}) => {
const program = Effect.scoped(
Effect.gen(function* () {
@@ -103,11 +114,16 @@ export const layer = Layer.effect(
Effect.map((output) => output.buffer.toString("utf8")),
Effect.forkScoped,
)
let observed = 0
const rows = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(input.parse),
Stream.filter((row): row is A => row !== undefined),
Stream.tap((row) => {
if (!input.onItem || observed++ >= input.limit) return Effect.void
return input.onItem(row)
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
@@ -137,31 +153,82 @@ export const layer = Layer.effect(
}
return Service.of({
files: (input) =>
glob: (input) =>
run<string>({
...input,
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
args: [
"--no-config",
"--files",
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
"--glob=!**/.git/**",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
"--glob=!.*",
"--glob=!**/.*",
".",
],
parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
parse: (line) =>
Effect.succeed(
line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/"),
),
}).pipe(
Effect.map((result) =>
result.items.map((relative) => {
const absolute = path.resolve(input.cwd, relative)
return new Entry({
path: RelativePath.make(relative),
type: "file",
mime: FSUtil.mimeType(absolute),
})
}),
),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
find: (input) =>
run<Entry>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
args: [
"--no-config",
"--files",
"--glob=!**/.git/**",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
".",
],
parse: (line) => {
const relative = line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Effect.succeed(
new Entry({
path: RelativePath.make(relative),
type: "file",
mime: FSUtil.mimeType(path.resolve(input.cwd, relative)),
}),
)
},
onItem: input.onEntry,
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) =>
run<Match>({
run<RawMatchData>({
...input,
args: [
"--no-config",
"--json",
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
"--hidden",
"--glob=!**/.git/**",
"--no-messages",
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!.*",
"--glob=!**/.*",
"--",
input.pattern,
input.file ?? ".",
@@ -180,13 +247,41 @@ export const layer = Layer.effect(
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
}),
),
}),
}).pipe(
Effect.map((result) =>
result.items.map((match) => {
const relative = match.path.text
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
const absolute = path.resolve(input.cwd, relative)
return new Match({
entry: new Entry({
path: RelativePath.make(relative),
type: "file",
mime: FSUtil.mimeType(absolute),
}),
line: match.line_number,
offset: match.absolute_offset,
text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
submatches: match.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
end: submatch.end,
})),
})
}),
),
),
})
}),
).pipe(Layer.provide(FileSystemRipgrep.defaultLayer))
)
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
+130
View File
@@ -0,0 +1,130 @@
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { which } from "../util/which"
export namespace RipgrepBinary {
const VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
interface Interface {
readonly filepath: Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/RipgrepBinary") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const spawner = yield* ChildProcessSpawner
const run = Effect.fnUntraced(function* (command: string, args: string[]) {
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
const [stdout, stderr, code] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
return { stdout, stderr, code }
}, Effect.scoped)
const extract = Effect.fnUntraced(function* (
archive: string,
config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string,
) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
if (config.extension === "zip") {
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
const result = yield* run(shell, [
"-NoProfile",
"-NonInteractive",
"-Command",
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
])
if (result.code !== 0)
throw new Error(
result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`,
)
}
if (config.extension === "tar.gz") {
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
if (result.code !== 0)
throw new Error(
result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`,
)
}
const extracted = path.join(
dir,
`ripgrep-${VERSION}-${config.platform}`,
process.platform === "win32" ? "rg.exe" : "rg",
)
if (!(yield* fs.isFile(extracted))) throw new Error(`ripgrep archive did not contain executable: ${extracted}`)
yield* fs.copyFile(extracted, target)
if (process.platform !== "win32") yield* fs.chmod(target, 0o755)
}, Effect.scoped)
return Service.of({
filepath: yield* Effect.cached(
Effect.gen(function* () {
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new Error(`unsupported platform for ripgrep: ${platformKey}`)
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
yield* Effect.logInfo("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
)
if (bytes.byteLength === 0) throw new Error(`failed to download ripgrep from ${url}`)
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
yield* extract(archive, config, target)
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
return target
}),
),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
}
+5 -6
View File
@@ -1,9 +1,8 @@
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/llm"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { NonNegativeInt } from "../schema"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { FileAttachment, Prompt } from "./prompt"
import { SessionSchema } from "./schema"
@@ -360,8 +359,8 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
},
})
export type Progress = typeof Progress.Type
@@ -371,8 +370,8 @@ export namespace Tool {
...options,
schema: {
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({
-1
View File
@@ -349,6 +349,5 @@ const toMessage = (input: Admitted) =>
text: input.prompt.text,
files: input.prompt.files,
agents: input.prompt.agents,
references: input.prompt.references,
time: { created: input.timeCreated },
})
+1 -1
View File
@@ -5,4 +5,4 @@ export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs("sessionID", sessionID))
) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID }))
@@ -132,7 +132,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
references: event.data.prompt.references,
time: { created: event.data.timestamp },
}),
)
+7 -9
View File
@@ -1,9 +1,8 @@
export * as SessionMessage from "./message"
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/llm"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { ModelV2 } from "../model"
import { ToolOutput } from "../tool-output"
import { V2Schema } from "../v2-schema"
import { SessionEvent } from "./event"
import { Prompt } from "./prompt"
@@ -37,7 +36,6 @@ export class User extends Schema.Class<User>("Session.Message.User")({
text: Prompt.fields.text,
files: Prompt.fields.files,
agents: Prompt.fields.agents,
references: Prompt.fields.references,
type: Schema.Literal("user"),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
@@ -77,25 +75,25 @@ export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Me
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Message.ToolState.Running")({
status: Schema.Literal("running"),
input: Schema.Record(Schema.String, Schema.Unknown),
structured: ToolOutput.Structured,
content: ToolOutput.Content.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Any),
content: ToolContent.pipe(Schema.Array),
}) {}
export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Session.Message.ToolState.Completed")({
status: Schema.Literal("completed"),
input: Schema.Record(Schema.String, Schema.Unknown),
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
content: ToolOutput.Content.pipe(Schema.Array),
content: ToolContent.pipe(Schema.Array),
outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths,
structured: ToolOutput.Structured,
structured: Schema.Record(Schema.String, Schema.Any),
result: SessionEvent.Tool.Success.data.fields.result,
}) {}
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Message.ToolState.Error")({
status: Schema.Literal("error"),
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolOutput.Content.pipe(Schema.Array),
structured: ToolOutput.Structured,
content: ToolContent.pipe(Schema.Array),
structured: Schema.Record(Schema.String, Schema.Any),
error: SessionEvent.UnknownError,
result: SessionEvent.Tool.Failed.data.fields.result,
}) {}
+2
View File
@@ -4,6 +4,7 @@ import { and, desc, eq, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LayerNode } from "../effect/layer-node"
import { SessionEvent } from "./event"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
@@ -447,3 +448,4 @@ export const layer = Layer.effectDiscard(
)
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
export const node = LayerNode.make(layer, [EventV2.node, Database.node])
+1 -15
View File
@@ -29,32 +29,18 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
source: Source.pipe(Schema.optional),
}) {}
export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
name: Schema.String,
kind: Schema.Literals(["local", "git", "invalid"]),
uri: Schema.String.pipe(Schema.optional),
repository: Schema.String.pipe(Schema.optional),
branch: Schema.String.pipe(Schema.optional),
target: Schema.String.pipe(Schema.optional),
targetUri: Schema.String.pipe(Schema.optional),
problem: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) {}
export class Prompt extends Schema.Class<Prompt>("Prompt")({
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
}) {
static readonly equivalence = Schema.toEquivalence(Prompt)
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents" | "references">) {
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents">) {
return new Prompt({
text: input.text,
...(input.files === undefined ? {} : { files: input.files }),
...(input.agents === undefined ? {} : { agents: input.agents }),
...(input.references === undefined ? {} : { references: input.references }),
})
}
}
@@ -1,11 +1,4 @@
import {
ToolOutput as LLMToolOutput,
type LLMEvent,
type ProviderMetadata,
type ToolOutput as LLMToolOutputType,
type ToolResultValue,
type Usage,
} from "@opencode-ai/llm"
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import { DateTime, Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
@@ -45,13 +38,13 @@ const message = (value: unknown) => {
}
}
type ToolOutput =
| { readonly structured: Record<string, unknown>; readonly content: LLMToolOutputType["content"] }
type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: { readonly type: "unknown"; readonly message: string } }
const settledOutput = (value: LLMToolOutputType | undefined, result: ToolResultValue): ToolOutput => {
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } }
const settled = value ?? LLMToolOutput.fromResultValue(result)
const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content }
}
@@ -38,9 +38,8 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
if (tool.state.status === "completed") {
// TODO: Materialize remote URL and managed file sources before provider-history lowering.
// ToolOutput.toResultValue intentionally rejects unmaterialized sources rather than
// guessing whether a provider can fetch them or leaking host-local resource paths.
// TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result =
tool.provider?.executed === true && tool.state.result !== undefined
? tool.state.result
@@ -105,7 +104,6 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
metadata: {
...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}),
...(message.references?.length ? { references: message.references } : {}),
},
}),
]
+4 -11
View File
@@ -6,7 +6,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { AbsolutePath } from "../schema"
import * as Log from "../util/log"
const skillConcurrency = 4
const fileConcurrency = 8
@@ -71,7 +70,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const log = Log.create({ service: "skill-discovery" })
const http = (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
@@ -87,7 +85,7 @@ export const layer = Layer.effect(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
Effect.catch((error) => Effect.sync(() => log.error("failed to download skill file", { url, error }))),
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
)
})
@@ -100,10 +98,9 @@ export const layer = Layer.effect(
HttpClientRequest.acceptJson,
http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((error) => {
log.error("failed to fetch skill index", { url: index, error })
return Effect.succeed(undefined)
}),
Effect.catch((error) =>
Effect.logError("failed to fetch skill index", { url: index, error }).pipe(Effect.as(undefined)),
),
)
if (!data) return []
@@ -111,17 +108,14 @@ export const layer = Layer.effect(
return yield* Effect.forEach(
data.skills.flatMap((skill) => {
if (!isSafeSegment(skill.name)) {
log.warn("skill entry has unsafe name", { url: index, skill: skill.name })
return []
}
if (!skill.files.includes("SKILL.md") && !skill.files.includes(`${skill.name}.md`)) {
log.warn("skill entry missing Markdown definition", { url: index, skill: skill.name })
return []
}
const root = path.resolve(sourceRoot, skill.name)
if (!FSUtil.contains(sourceRoot, root) || root === sourceRoot) {
log.warn("skill entry escapes cache root", { url: index, skill: skill.name })
return []
}
@@ -144,7 +138,6 @@ export const layer = Layer.effect(
}
})
if (files.some((file) => file === undefined)) {
log.warn("skill entry has unsafe file", { url: index, skill: skill.name })
return []
}
return [{ skill, root, files: files as { url: string; destination: string }[] }]
-11
View File
@@ -1,11 +0,0 @@
export * as ToolOutput from "./tool-output"
export {
ToolContent as Content,
ToolFileContent as FileContent,
ToolTextContent as TextContent,
toolFile as file,
toolText as text,
} from "@opencode-ai/llm"
import { Schema } from "effect"
export const Structured = Schema.Record(Schema.String, Schema.Any)
+2 -2
View File
@@ -1,6 +1,6 @@
export * as ApplyPatchTool from "./apply-patch"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -59,7 +59,7 @@ export const layer = Layer.effectDiscard(
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const fail = (path: string) => {
+2 -2
View File
@@ -1,7 +1,7 @@
export * as BashTool from "./bash"
import path from "path"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config"
@@ -119,7 +119,7 @@ export const layer = Layer.effectDiscard(
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }],
execute: (input, context) =>
Effect.gen(function* () {
const source = {
+2 -1
View File
@@ -8,6 +8,7 @@ import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { QuestionTool } from "./question"
import { ReadTool } from "./read"
import { ReadToolFileSystem } from "./read-filesystem"
import { SkillTool } from "./skill"
import { TodoWriteTool } from "./todowrite"
import { WebFetchTool } from "./webfetch"
@@ -34,7 +35,7 @@ export const locationLayer = Layer.mergeAll(
GlobTool.layer,
GrepTool.layer,
QuestionTool.layer,
ReadTool.layer,
ReadTool.layer.pipe(Layer.provide(ReadToolFileSystem.layer)),
SkillTool.layer,
TodoWriteTool.layer,
WebFetchTool.layer,
+5 -6
View File
@@ -2,12 +2,11 @@
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
* Location. Absolute paths inside that Location are accepted, while explicit
* absolute external paths retain mutation capability through a separate
* external_directory approval before edit approval. Named project references
* are read-oriented and deliberately are not accepted by mutation tools.
* external_directory approval before edit approval.
*/
export * as EditTool from "./edit"
import { ToolFailure, toolText } from "@opencode-ai/llm"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
@@ -21,7 +20,7 @@ export const name = "edit"
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
}),
oldString: Schema.String.annotate({ description: "Exact text to replace" }),
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
@@ -100,11 +99,11 @@ export const layer = Layer.effectDiscard(
[name]: Tool.withPermission(
Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
{ type: "text", text: toModelOutput(output, input.oldString, input.newString) },
],
execute: (input, context) => {
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>

Some files were not shown because too many files have changed in this diff Show More