mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-15 10:24:53 +00:00
Compare commits
18 Commits
followup-q
...
kit/instan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ff4bf3fd9 | ||
|
|
fb92bd470c | ||
|
|
02f8a24e23 | ||
|
|
467e5689ec | ||
|
|
fba752a501 | ||
|
|
87b2a9d749 | ||
|
|
8df7ccc304 | ||
|
|
2c36bf9490 | ||
|
|
bddf830083 | ||
|
|
50c1d0a43b | ||
|
|
60b8041ebb | ||
|
|
3b2a2c461d | ||
|
|
6706358a6e | ||
|
|
f6409759e5 | ||
|
|
f9d99f044d | ||
|
|
bbd5faf5cd | ||
|
|
aeb7d99d20 | ||
|
|
3695057bee |
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
- name: Read Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
||||
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
|
||||
45
bun.lock
45
bun.lock
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -115,7 +115,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -142,7 +142,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -166,7 +166,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -190,7 +190,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -223,7 +223,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -266,7 +266,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -295,7 +295,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -311,7 +311,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -356,6 +356,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@opentui/core": "0.1.99",
|
||||
@@ -450,7 +451,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -485,7 +486,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -498,9 +499,19 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -535,7 +546,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -584,7 +595,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -595,7 +606,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1533,6 +1544,8 @@
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
|
||||
|
||||
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
|
||||
|
||||
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
||||
|
||||
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-1qeXIfqPCo5Px0ebdTvL09kav5Ib79E35Ed1FgiLnPY=",
|
||||
"aarch64-linux": "sha256-987XZxDqDf6gm0kjXg606BSEYvryrUD8vZopsADQIN8=",
|
||||
"aarch64-darwin": "sha256-RI3D3bPxotudTWmdQNIPZ/oBrmDl5PAdJGc93M4bKHs=",
|
||||
"x86_64-darwin": "sha256-LE6hrDPXWKjcePc1nv+O6tIN0ZXUrzWI1XBN6Fm/NKw="
|
||||
"x86_64-linux": "sha256-2p0WOk7qE2zC8S5mIDmpefjhJv8zhsgT33crGFWl6LI=",
|
||||
"aarch64-linux": "sha256-sMW7pXoFtV6r4ySoYB8ISqKFHFeAMmiCUvHtiplwxak=",
|
||||
"aarch64-darwin": "sha256-/4g2e39t9huLXOObdolDPmImGNhndOsxeAGJjw+bE8g=",
|
||||
"x86_64-darwin": "sha256-SJ9y58ZwQnXhMtus0ITQo3sfHzHfOSPkJRK24n5pnBw="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
sysctl,
|
||||
makeBinaryWrapper,
|
||||
models-dev,
|
||||
ripgrep,
|
||||
installShellFiles,
|
||||
versionCheckHook,
|
||||
writableTmpDirAsHomeHook,
|
||||
@@ -52,25 +51,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
installPhase =
|
||||
''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
|
||||
install -Dm644 schema.json $out/share/opencode/schema.json
|
||||
|
||||
wrapProgram $out/bin/opencode \
|
||||
--prefix PATH : ${
|
||||
lib.makeBinPath (
|
||||
[
|
||||
ripgrep
|
||||
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
|
||||
install -Dm644 schema.json $out/share/opencode/schema.json
|
||||
''
|
||||
# bun runs sysctl to detect if dunning on rosetta2
|
||||
+ lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
|
||||
wrapProgram $out/bin/opencode \
|
||||
--prefix PATH : ${
|
||||
lib.makeBinPath [
|
||||
sysctl
|
||||
]
|
||||
# bun runs sysctl to detect if dunning on rosetta2
|
||||
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
|
||||
)
|
||||
}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
''
|
||||
+ ''
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
||||
# trick yargs into also generating zsh completions
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"generate:check": "bun ./script/check-generate.ts",
|
||||
"random": "echo 'Random script'",
|
||||
"hello": "echo 'Hello World!'",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -122,20 +122,21 @@ export async function bootstrapGlobal(input: {
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
showErrors({
|
||||
errors: errors(await runAll(fast)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
await runAll(fast)
|
||||
// showErrors({
|
||||
// errors: errors(await runAll(fast)),
|
||||
// title: input.requestFailedTitle,
|
||||
// translate: input.translate,
|
||||
// formatMoreCount: input.formatMoreCount,
|
||||
// })
|
||||
await waitForPaint()
|
||||
showErrors({
|
||||
errors: errors(await runAll(slow)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
await runAll(slow)
|
||||
// showErrors({
|
||||
// errors: errors(),
|
||||
// title: input.requestFailedTitle,
|
||||
// translate: input.translate,
|
||||
// formatMoreCount: input.formatMoreCount,
|
||||
// })
|
||||
input.setGlobalStore("ready", true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,5 +11,6 @@ class LimitError extends Error {
|
||||
this.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
export class RateLimitError extends LimitError {}
|
||||
export class FreeUsageLimitError extends LimitError {}
|
||||
export class SubscriptionUsageLimitError extends LimitError {}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
MonthlyLimitError,
|
||||
UserLimitError,
|
||||
ModelError,
|
||||
RateLimitError,
|
||||
FreeUsageLimitError,
|
||||
SubscriptionUsageLimitError,
|
||||
} from "./error"
|
||||
@@ -35,7 +36,8 @@ import { anthropicHelper } from "./provider/anthropic"
|
||||
import { googleHelper } from "./provider/google"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
import { oaCompatHelper } from "./provider/openai-compatible"
|
||||
import { createRateLimiter } from "./rateLimiter"
|
||||
import { createRateLimiter as createIpRateLimiter } from "./ipRateLimiter"
|
||||
import { createRateLimiter as createKeyRateLimiter } from "./keyRateLimiter"
|
||||
import { createDataDumper } from "./dataDumper"
|
||||
import { createTrialLimiter } from "./trialLimiter"
|
||||
import { createStickyTracker } from "./stickyProviderTracker"
|
||||
@@ -92,6 +94,8 @@ export async function handler(
|
||||
const isStream = opts.parseIsStream(url, body)
|
||||
const rawIp = input.request.headers.get("x-real-ip") ?? ""
|
||||
const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp
|
||||
const rawZenApiKey = opts.parseApiKey(input.request.headers)
|
||||
const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey
|
||||
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
|
||||
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||
@@ -106,19 +110,15 @@ export async function handler(
|
||||
const zenData = ZenData.list(opts.modelList)
|
||||
const modelInfo = validateModel(zenData, model)
|
||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip)
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
|
||||
const trialProviders = await trialLimiter?.check()
|
||||
const rateLimiter = createRateLimiter(
|
||||
modelInfo.id,
|
||||
modelInfo.allowAnonymous,
|
||||
modelInfo.rateLimit,
|
||||
ip,
|
||||
input.request,
|
||||
)
|
||||
const rateLimiter = modelInfo.allowAnonymous
|
||||
? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request)
|
||||
: createKeyRateLimiter(modelInfo.id, zenApiKey, input.request)
|
||||
await rateLimiter?.check()
|
||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
||||
const stickyProvider = await stickyTracker?.get()
|
||||
const authInfo = await authenticate(modelInfo)
|
||||
const authInfo = await authenticate(modelInfo, zenApiKey)
|
||||
const billingSource = validateBilling(authInfo, modelInfo)
|
||||
logger.metric({ source: billingSource })
|
||||
|
||||
@@ -363,7 +363,11 @@ export async function handler(
|
||||
{ status: 401 },
|
||||
)
|
||||
|
||||
if (error instanceof FreeUsageLimitError || error instanceof SubscriptionUsageLimitError) {
|
||||
if (
|
||||
error instanceof RateLimitError ||
|
||||
error instanceof FreeUsageLimitError ||
|
||||
error instanceof SubscriptionUsageLimitError
|
||||
) {
|
||||
const headers = new Headers()
|
||||
if (error.retryAfter) {
|
||||
headers.set("retry-after", String(error.retryAfter))
|
||||
@@ -392,7 +396,7 @@ export async function handler(
|
||||
function validateModel(zenData: ZenData, reqModel: string) {
|
||||
if (!(reqModel in zenData.models)) throw new ModelError(t("zen.api.error.modelNotSupported", { model: reqModel }))
|
||||
|
||||
const modelId = reqModel as keyof typeof zenData.models
|
||||
const modelId = reqModel
|
||||
const modelData = Array.isArray(zenData.models[modelId])
|
||||
? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
|
||||
: zenData.models[modelId]
|
||||
@@ -492,9 +496,8 @@ export async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(modelInfo: ModelInfo) {
|
||||
const apiKey = opts.parseApiKey(input.request.headers)
|
||||
if (!apiKey || apiKey === "public") {
|
||||
async function authenticate(modelInfo: ModelInfo, zenApiKey?: string) {
|
||||
if (!zenApiKey) {
|
||||
if (modelInfo.allowAnonymous) return
|
||||
throw new AuthError(t("zen.api.error.missingApiKey"))
|
||||
}
|
||||
@@ -573,7 +576,7 @@ export async function handler(
|
||||
isNull(LiteTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.where(and(eq(KeyTable.key, zenApiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,14 +6,7 @@ import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||
|
||||
export function createRateLimiter(
|
||||
modelId: string,
|
||||
allowAnonymous: boolean | undefined,
|
||||
rateLimit: number | undefined,
|
||||
rawIp: string,
|
||||
request: Request,
|
||||
) {
|
||||
if (!allowAnonymous) return
|
||||
export function createRateLimiter(modelId: string, rateLimit: number | undefined, rawIp: string, request: Request) {
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
|
||||
const limits = Subscription.getFreeLimits()
|
||||
39
packages/console/app/src/routes/zen/util/keyRateLimiter.ts
Normal file
39
packages/console/app/src/routes/zen/util/keyRateLimiter.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
import { RateLimitError } from "./error"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
|
||||
export function createRateLimiter(modelId: string, zenApiKey: string | undefined, request: Request) {
|
||||
if (!zenApiKey) return
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
|
||||
const LIMIT = 100
|
||||
const yyyyMMddHHmm = new Date(Date.now())
|
||||
.toISOString()
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12)
|
||||
const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}`
|
||||
|
||||
return {
|
||||
check: async () => {
|
||||
const rows = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count })
|
||||
.from(KeyRateLimitTable)
|
||||
.where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))),
|
||||
).then((rows) => rows[0])
|
||||
const count = rows?.count ?? 0
|
||||
|
||||
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
|
||||
},
|
||||
track: async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(KeyRateLimitTable)
|
||||
.values({ key: zenApiKey, interval, count: 1 })
|
||||
.onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,14 @@ type Usage = {
|
||||
total_tokens?: number
|
||||
// used by moonshot
|
||||
cached_tokens?: number
|
||||
// used by xai
|
||||
// used by xai & alibaba
|
||||
prompt_tokens_details?: {
|
||||
text_tokens?: number
|
||||
audio_tokens?: number
|
||||
image_tokens?: number
|
||||
cached_tokens?: number
|
||||
// used by alibaba
|
||||
cache_creation_input_tokens?: number
|
||||
}
|
||||
completion_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
@@ -62,6 +64,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
|
||||
const outputTokens = usage.completion_tokens ?? 0
|
||||
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
const cacheWriteTokens = usage.prompt_tokens_details?.cache_creation_input_tokens ?? undefined
|
||||
|
||||
if (adjustCacheUsage && !cacheReadTokens) {
|
||||
cacheReadTokens = Math.floor(inputTokens * 0.9)
|
||||
@@ -72,7 +75,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
cacheWrite5mTokens: cacheWriteTokens,
|
||||
cacheWrite1hTokens: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getRetryAfterDay } from "../src/routes/zen/util/rateLimiter"
|
||||
import { getRetryAfterDay } from "../src/routes/zen/util/ipRateLimiter"
|
||||
|
||||
describe("getRetryAfterDay", () => {
|
||||
test("returns full day at midnight UTC", () => {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE `key_rate_limit` (
|
||||
`key` varchar(255) NOT NULL,
|
||||
`interval` varchar(12) NOT NULL,
|
||||
`count` int NOT NULL,
|
||||
CONSTRAINT PRIMARY KEY(`key`,`interval`)
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
ALTER TABLE `key_rate_limit` MODIFY COLUMN `interval` varchar(20) NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
ALTER TABLE `key_rate_limit` MODIFY COLUMN `interval` varchar(40) NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -26,7 +26,7 @@ export namespace ZenData {
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
|
||||
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
||||
trialProviders: z.array(z.string()).optional(),
|
||||
trialProvider: z.string().optional(),
|
||||
trialEnded: z.boolean().optional(),
|
||||
fallbackProvider: z.string().optional(),
|
||||
rateLimit: z.number().optional(),
|
||||
@@ -45,7 +45,7 @@ export namespace ZenData {
|
||||
|
||||
const ProviderSchema = z.object({
|
||||
api: z.string(),
|
||||
apiKey: z.string(),
|
||||
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
|
||||
format: FormatSchema.optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
@@ -54,7 +54,10 @@ export namespace ZenData {
|
||||
})
|
||||
|
||||
const ModelsSchema = z.object({
|
||||
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
|
||||
zenModels: z.record(
|
||||
z.string(),
|
||||
z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
|
||||
),
|
||||
liteModels: z.record(
|
||||
z.string(),
|
||||
z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
|
||||
@@ -99,10 +102,66 @@ export namespace ZenData {
|
||||
Resource.ZEN_MODELS29.value +
|
||||
Resource.ZEN_MODELS30.value,
|
||||
)
|
||||
const { models, liteModels, providers } = ModelsSchema.parse(json)
|
||||
const { zenModels, liteModels, providers } = ModelsSchema.parse(json)
|
||||
const compositeProviders = Object.fromEntries(
|
||||
Object.entries(providers).map(([id, provider]) => [
|
||||
id,
|
||||
typeof provider.apiKey === "string"
|
||||
? [{ id: id, key: provider.apiKey }]
|
||||
: Object.entries(provider.apiKey).map(([kid, key]) => ({
|
||||
id: `${id}.${kid}`,
|
||||
key,
|
||||
})),
|
||||
]),
|
||||
)
|
||||
return {
|
||||
models: modelList === "lite" ? liteModels : models,
|
||||
providers,
|
||||
providers: Object.fromEntries(
|
||||
Object.entries(providers).flatMap(([providerId, provider]) =>
|
||||
compositeProviders[providerId].map((p) => [p.id, { ...provider, apiKey: p.key }]),
|
||||
),
|
||||
),
|
||||
models: (() => {
|
||||
const normalize = (model: z.infer<typeof ModelSchema>) => {
|
||||
const composite = model.providers.find((p) => compositeProviders[p.id].length > 1)
|
||||
if (!composite)
|
||||
return {
|
||||
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
|
||||
}
|
||||
|
||||
const weightMulti = compositeProviders[composite.id].length
|
||||
|
||||
return {
|
||||
trialProvider: (() => {
|
||||
if (!model.trialProvider) return undefined
|
||||
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
|
||||
return [model.trialProvider]
|
||||
})(),
|
||||
providers: model.providers.flatMap((p) =>
|
||||
p.id === composite.id
|
||||
? compositeProviders[p.id].map((sub) => ({
|
||||
...p,
|
||||
id: sub.id,
|
||||
weight: p.weight ?? 1,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
...p,
|
||||
weight: (p.weight ?? 1) * weightMulti,
|
||||
},
|
||||
],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(modelList === "lite" ? liteModels : zenModels).map(([modelId, model]) => {
|
||||
const n = Array.isArray(model)
|
||||
? model.map((m) => ({ ...m, ...normalize(m) }))
|
||||
: { ...model, ...normalize(model) }
|
||||
return [modelId, n]
|
||||
}),
|
||||
)
|
||||
})(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,3 +20,13 @@ export const IpRateLimitTable = mysqlTable(
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.ip, table.interval] })],
|
||||
)
|
||||
|
||||
export const KeyRateLimitTable = mysqlTable(
|
||||
"key_rate_limit",
|
||||
{
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
interval: varchar("interval", { length: 40 }).notNull(),
|
||||
count: int("count").notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.key, table.interval] })],
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.4/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.4/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.4/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.4/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.4/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -111,6 +111,7 @@
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
310
packages/opencode/specs/effect/instance-context.md
Normal file
310
packages/opencode/specs/effect/instance-context.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Instance context migration
|
||||
|
||||
Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope.
|
||||
|
||||
## Goal
|
||||
|
||||
End state:
|
||||
|
||||
- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)`
|
||||
- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters
|
||||
- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map<string, Promise<InstanceContext>>`
|
||||
- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree
|
||||
|
||||
## Current split
|
||||
|
||||
Today `src/project/instance.ts` still owns two separate concerns:
|
||||
|
||||
- ambient current-instance context through `LocalContext` / `AsyncLocalStorage`
|
||||
- per-directory boot and deduplication through `cache: Map<string, Promise<InstanceContext>>`
|
||||
|
||||
At the same time, the Effect side already exists:
|
||||
|
||||
- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef`
|
||||
- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context
|
||||
- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed
|
||||
|
||||
That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth".
|
||||
|
||||
## End state shape
|
||||
|
||||
Near-term target shape:
|
||||
|
||||
```ts
|
||||
InstanceScope.with({ directory, workspaceID }, effect)
|
||||
```
|
||||
|
||||
Responsibilities of `InstanceScope.with(...)`:
|
||||
|
||||
- resolve `directory`, `project`, and `worktree`
|
||||
- acquire or reuse the scoped per-directory instance environment
|
||||
- provide `InstanceRef` and `WorkspaceRef`
|
||||
- run the caller's Effect inside that environment
|
||||
|
||||
Code inside the boundary should then do one of these:
|
||||
|
||||
```ts
|
||||
const ctx = yield * InstanceState.context
|
||||
const dir = yield * InstanceState.directory
|
||||
```
|
||||
|
||||
Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order.
|
||||
|
||||
## Migration phases
|
||||
|
||||
### Phase 1: stop expanding the legacy surface
|
||||
|
||||
Rules for all new code:
|
||||
|
||||
- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code
|
||||
- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet
|
||||
- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code
|
||||
|
||||
Success condition:
|
||||
|
||||
- the file inventory below only shrinks from here
|
||||
|
||||
### Phase 2: remove direct sync getter reads from Effect services
|
||||
|
||||
Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access.
|
||||
|
||||
Primary batch, highest payoff:
|
||||
|
||||
- `src/file/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/worktree/index.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/format/formatter.ts`
|
||||
- `src/session/index.ts`
|
||||
- `src/project/vcs.ts`
|
||||
|
||||
Mechanical replacement rule:
|
||||
|
||||
- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory`
|
||||
- `Instance.worktree` -> `ctx.worktree`
|
||||
- `Instance.project` -> `ctx.project`
|
||||
|
||||
Do not thread strings manually through every public method if the service already has access to Effect context.
|
||||
|
||||
### Phase 3: convert entry boundaries to provide instance refs directly
|
||||
|
||||
After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly.
|
||||
|
||||
Main boundaries:
|
||||
|
||||
- HTTP server middleware and experimental `HttpApi` entrypoints
|
||||
- CLI commands
|
||||
- TUI worker / attach / thread entrypoints
|
||||
- tool execution entrypoints
|
||||
|
||||
These boundaries should become Effect-native wrappers that:
|
||||
|
||||
- decode directory / workspace inputs
|
||||
- resolve the instance context once
|
||||
- provide `InstanceRef` and `WorkspaceRef`
|
||||
- run the requested Effect
|
||||
|
||||
At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path.
|
||||
|
||||
### Phase 4: replace promise boot cache with scoped instance runtime
|
||||
|
||||
Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`.
|
||||
|
||||
Target replacement:
|
||||
|
||||
- keyed scoped runtime or keyed layer acquisition for each directory
|
||||
- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager
|
||||
- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map
|
||||
|
||||
This phase should absorb the current responsibilities of:
|
||||
|
||||
- `cache` in `src/project/instance.ts`
|
||||
- `boot(...)`
|
||||
- most of `disposeInstance(...)`
|
||||
- manual `reload(...)` / `disposeAll()` fan-out logic
|
||||
|
||||
### Phase 5: shrink ALS to callback bridges only
|
||||
|
||||
Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously.
|
||||
|
||||
Known bridge cases today:
|
||||
|
||||
- `src/file/watcher.ts`
|
||||
- `src/session/llm.ts`
|
||||
- some LSP and plugin callback paths
|
||||
|
||||
If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too.
|
||||
|
||||
### Phase 6: delete the legacy sync API
|
||||
|
||||
Only after earlier phases land:
|
||||
|
||||
- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project`
|
||||
- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely
|
||||
- remove the ALS fallback from `InstanceState.context`
|
||||
|
||||
## Inventory of direct legacy usage
|
||||
|
||||
Direct legacy usage means any source file that still calls one of:
|
||||
|
||||
- `Instance.current`
|
||||
- `Instance.directory`
|
||||
- `Instance.worktree`
|
||||
- `Instance.project`
|
||||
- `Instance.provide(...)`
|
||||
- `Instance.bind(...)`
|
||||
- `Instance.restore(...)`
|
||||
- `Instance.reload(...)`
|
||||
- `Instance.dispose()` / `Instance.disposeAll()`
|
||||
|
||||
Current total: `54` files in `packages/opencode/src`.
|
||||
|
||||
### Core bridge and plumbing
|
||||
|
||||
These files define or adapt the current bridge. They should change last, after callers have moved.
|
||||
|
||||
- `src/project/instance.ts`
|
||||
- `src/effect/run-service.ts`
|
||||
- `src/effect/instance-state.ts`
|
||||
- `src/project/bootstrap.ts`
|
||||
- `src/config/config.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS
|
||||
|
||||
### HTTP and server boundaries
|
||||
|
||||
These are the current request-entry seams that still create or consume instance context through the legacy helper.
|
||||
|
||||
- `src/server/instance/middleware.ts`
|
||||
- `src/server/instance/index.ts`
|
||||
- `src/server/instance/project.ts`
|
||||
- `src/server/instance/workspace.ts`
|
||||
- `src/server/instance/file.ts`
|
||||
- `src/server/instance/experimental.ts`
|
||||
- `src/server/instance/global.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef`
|
||||
- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS
|
||||
|
||||
### CLI and TUI boundaries
|
||||
|
||||
These commands still enter an instance through `Instance.provide(...)` or read sync getters directly.
|
||||
|
||||
- `src/cli/bootstrap.ts`
|
||||
- `src/cli/cmd/agent.ts`
|
||||
- `src/cli/cmd/debug/agent.ts`
|
||||
- `src/cli/cmd/debug/ripgrep.ts`
|
||||
- `src/cli/cmd/github.ts`
|
||||
- `src/cli/cmd/import.ts`
|
||||
- `src/cli/cmd/mcp.ts`
|
||||
- `src/cli/cmd/models.ts`
|
||||
- `src/cli/cmd/plug.ts`
|
||||
- `src/cli/cmd/pr.ts`
|
||||
- `src/cli/cmd/providers.ts`
|
||||
- `src/cli/cmd/stats.ts`
|
||||
- `src/cli/cmd/tui/attach.ts`
|
||||
- `src/cli/cmd/tui/plugin/runtime.ts`
|
||||
- `src/cli/cmd/tui/thread.ts`
|
||||
- `src/cli/cmd/tui/worker.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)`
|
||||
- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider
|
||||
|
||||
### Tool boundary code
|
||||
|
||||
These tools mostly use direct getters for path resolution and repo-relative display logic.
|
||||
|
||||
- `src/tool/apply_patch.ts`
|
||||
- `src/tool/bash.ts`
|
||||
- `src/tool/edit.ts`
|
||||
- `src/tool/lsp.ts`
|
||||
- `src/tool/multiedit.ts`
|
||||
- `src/tool/plan.ts`
|
||||
- `src/tool/read.ts`
|
||||
- `src/tool/write.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- expose the current instance as an explicit Effect dependency for tool execution
|
||||
- keep path logic local; avoid introducing another global singleton for tool state
|
||||
|
||||
### Effect services still reading ambient instance state
|
||||
|
||||
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
|
||||
|
||||
- `src/agent/agent.ts`
|
||||
- `src/config/tui-migrate.ts`
|
||||
- `src/file/index.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/format/formatter.ts`
|
||||
- `src/lsp/client.ts`
|
||||
- `src/lsp/index.ts`
|
||||
- `src/lsp/server.ts`
|
||||
- `src/mcp/index.ts`
|
||||
- `src/project/vcs.ts`
|
||||
- `src/provider/provider.ts`
|
||||
- `src/pty/index.ts`
|
||||
- `src/session/index.ts`
|
||||
- `src/session/instruction.ts`
|
||||
- `src/session/llm.ts`
|
||||
- `src/session/system.ts`
|
||||
- `src/sync/index.ts`
|
||||
- `src/worktree/index.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx`
|
||||
- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode
|
||||
|
||||
### Highest-churn hotspots
|
||||
|
||||
Current highest direct-usage counts by file:
|
||||
|
||||
- `src/file/index.ts` - `18`
|
||||
- `src/lsp/server.ts` - `14`
|
||||
- `src/worktree/index.ts` - `12`
|
||||
- `src/file/watcher.ts` - `9`
|
||||
- `src/cli/cmd/mcp.ts` - `8`
|
||||
- `src/format/formatter.ts` - `8`
|
||||
- `src/tool/apply_patch.ts` - `8`
|
||||
- `src/cli/cmd/github.ts` - `7`
|
||||
|
||||
These files should drive the first measurable burn-down.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`.
|
||||
2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`.
|
||||
3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern.
|
||||
4. Convert remaining CLI and tool boundaries.
|
||||
5. Replace the promise cache with a keyed scoped runtime or keyed layer map.
|
||||
6. Delete ALS fallback paths once only callback bridges still depend on them.
|
||||
|
||||
## Definition of done
|
||||
|
||||
This migration is done when all of the following are true:
|
||||
|
||||
- new requests and commands enter an instance by providing Effect context, not ALS
|
||||
- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current`
|
||||
- `Instance.provide(...)` is gone from normal request / CLI / tool execution
|
||||
- per-directory boot and disposal are handled by scoped Effect resources
|
||||
- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters
|
||||
|
||||
## Tracker and worktree
|
||||
|
||||
Active tracker items:
|
||||
|
||||
- `lh7l73` - overall `HttpApi` migration
|
||||
- `yobwlk` - remove direct `Instance.*` reads inside Effect services
|
||||
- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers
|
||||
|
||||
Dedicated worktree for this transition:
|
||||
|
||||
- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift`
|
||||
- branch: `kit/instance-effect-shift`
|
||||
@@ -13,6 +13,10 @@ Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `Ma
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
|
||||
## Instance context transition
|
||||
|
||||
See `instance-context.md` for the phased plan to remove the legacy ALS / promise-backed `Instance` helper and move request / CLI / tool boundaries onto Effect-provided instance scope.
|
||||
|
||||
## Service shape
|
||||
|
||||
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
|
||||
|
||||
666
packages/opencode/specs/effect/server-package.md
Normal file
666
packages/opencode/specs/effect/server-package.md
Normal file
@@ -0,0 +1,666 @@
|
||||
# Server package extraction
|
||||
|
||||
Practical reference for extracting a future `packages/server` from the current `packages/opencode` monolith while `packages/core` is still being migrated to Effect.
|
||||
|
||||
This document is intentionally execution-oriented.
|
||||
|
||||
It should give an agent enough context to land one incremental PR at a time without needing to rediscover the package strategy, route migration rules, or current constraints.
|
||||
|
||||
## Goal
|
||||
|
||||
Create `packages/server` as the home for:
|
||||
|
||||
- HTTP contract definitions
|
||||
- HTTP handler implementations
|
||||
- OpenAPI generation
|
||||
- eventual embeddable server APIs for Node apps
|
||||
|
||||
Do this without blocking on the full `packages/core` extraction.
|
||||
|
||||
## Future state
|
||||
|
||||
Target package layout:
|
||||
|
||||
- `packages/core` - all opencode services, Effect-first source of truth
|
||||
- `packages/server` - opencode server, with separate contract and implementation, still producing `openapi.json`
|
||||
- `packages/cli` - TUI + CLI entrypoints
|
||||
- `packages/sdk` - generated from the server OpenAPI spec, may add higher-level wrappers
|
||||
- `packages/plugin` - generated or semi-hand-rolled non-Effect package built from core plugin definitions
|
||||
|
||||
Desired user stories:
|
||||
|
||||
- import from `core` and build a custom agent or app-specific runtime
|
||||
- import from `server` and embed the full opencode server into an existing Node app
|
||||
- spawn the CLI and talk to the server through that boundary
|
||||
|
||||
## Current state
|
||||
|
||||
Everything still lives in `packages/opencode`.
|
||||
|
||||
Important current facts:
|
||||
|
||||
- there is no `packages/core` or `packages/cli` workspace yet
|
||||
- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
|
||||
- the main host server is still Hono-based in `src/server/server.ts`
|
||||
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
|
||||
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
|
||||
- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
|
||||
- that experimental slice is mounted under `/experimental/httpapi/question`
|
||||
- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
|
||||
|
||||
This means the package split should start from an extraction path, not from greenfield package ownership.
|
||||
|
||||
## Structural reference
|
||||
|
||||
Use `anomalyco/opentunnel` as the structural reference for `packages/server`.
|
||||
|
||||
The important pattern there is:
|
||||
|
||||
- `packages/core` owns services and domain schemas
|
||||
- `packages/server/src/definition/*` owns pure `HttpApi` contracts
|
||||
- `packages/server/src/api/*` owns `HttpApiBuilder.group(...)` implementations and server-side middleware wiring
|
||||
- `packages/server/src/index.ts` becomes the composition root only after the server package really owns runtime hosting
|
||||
|
||||
Relevant `opentunnel` files:
|
||||
|
||||
- `packages/server/src/definition/index.ts`
|
||||
- `packages/server/src/definition/tunnel.ts`
|
||||
- `packages/server/src/api/index.ts`
|
||||
- `packages/server/src/api/tunnel.ts`
|
||||
- `packages/server/src/api/client.ts`
|
||||
- `packages/server/src/index.ts`
|
||||
|
||||
The intended direction here is the same, but the current `opencode` package split is earlier in the migration.
|
||||
|
||||
That means:
|
||||
|
||||
- we should follow the same `definition` and `api` naming
|
||||
- we should keep contract and implementation as separate modules from the start
|
||||
- we should postpone the runtime composition root until `packages/core` exists enough to support it cleanly
|
||||
|
||||
## Key decision
|
||||
|
||||
Start `packages/server` as a contract and implementation package only.
|
||||
|
||||
Do not make it the runtime host yet.
|
||||
|
||||
Why:
|
||||
|
||||
- `packages/core` does not exist yet
|
||||
- the current server host still lives in `packages/opencode`
|
||||
- moving host ownership immediately would force a large package and runtime shuffle while Effect service extraction is still in flight
|
||||
- if `packages/server` imports services from `packages/opencode` while `packages/opencode` imports `packages/server` to host routes, we create a package cycle immediately
|
||||
|
||||
Short version:
|
||||
|
||||
1. create `packages/server`
|
||||
2. move pure `HttpApi` contracts there
|
||||
3. move handler factories there
|
||||
4. keep `packages/opencode` as the temporary Hono host
|
||||
5. merge `packages/server` OpenAPI with the legacy Hono OpenAPI during the transition
|
||||
6. move server hosting later, after `packages/core` exists enough
|
||||
|
||||
## Dependency rule
|
||||
|
||||
Phase 1 rule:
|
||||
|
||||
- `packages/server` must not import from `packages/opencode`
|
||||
|
||||
Allowed in phase 1:
|
||||
|
||||
- `packages/opencode` imports `packages/server`
|
||||
- `packages/server` accepts host-provided services, layers, or callbacks as inputs
|
||||
- `packages/server` may temporarily own transport-local placeholder schemas when a canonical shared schema does not exist yet
|
||||
|
||||
Future rule after `packages/core` exists:
|
||||
|
||||
- `packages/server` imports from `packages/core`
|
||||
- `packages/cli` imports from `packages/server` and `packages/core`
|
||||
- `packages/opencode` shrinks or disappears as package responsibilities are fully split
|
||||
|
||||
## HttpApi model
|
||||
|
||||
Use Effect v4 `HttpApi` as the source of truth for migrated HTTP routes.
|
||||
|
||||
Important properties from the current `effect` / `effect-smol` model:
|
||||
|
||||
- `HttpApi`, `HttpApiGroup`, and `HttpApiEndpoint` are pure contract definitions
|
||||
- handlers are implemented separately with `HttpApiBuilder.group(...)`
|
||||
- OpenAPI can be generated from the contract alone
|
||||
- auth and middleware can later be modeled with `HttpApiMiddleware.Service`
|
||||
- SSE and websocket routes are not good first-wave `HttpApi` targets
|
||||
|
||||
This package split should preserve that separation explicitly.
|
||||
|
||||
Default shape for migrated routes:
|
||||
|
||||
- contract lives in `packages/server/src/definition/*`
|
||||
- implementation lives in `packages/server/src/api/*`
|
||||
- host mounting stays outside for now
|
||||
|
||||
## OpenAPI rule
|
||||
|
||||
During the transition there is still one spec artifact.
|
||||
|
||||
Default rule:
|
||||
|
||||
- `packages/server` generates OpenAPI from `HttpApi` contract
|
||||
- `packages/opencode` keeps generating legacy OpenAPI from Hono routes
|
||||
- the temporary exported server spec is a merged document
|
||||
- `packages/sdk` continues consuming one `openapi.json`
|
||||
|
||||
Merge safety rules:
|
||||
|
||||
- fail on duplicate `path + method`
|
||||
- fail on duplicate `operationId`
|
||||
- prefer explicit summary, description, and operation ids on all new `HttpApi` endpoints
|
||||
|
||||
Practical implication:
|
||||
|
||||
- do not make the SDK consume two specs
|
||||
- do not switch SDK generation to `packages/server` only until enough of the route surface has moved
|
||||
|
||||
## Package shape
|
||||
|
||||
Minimum viable `packages/server`:
|
||||
|
||||
- `src/index.ts`
|
||||
- `src/definition/index.ts`
|
||||
- `src/definition/api.ts`
|
||||
- `src/definition/question.ts`
|
||||
- `src/api/index.ts`
|
||||
- `src/api/question.ts`
|
||||
- `src/openapi.ts`
|
||||
- `src/bridge/hono.ts`
|
||||
- `src/types.ts`
|
||||
|
||||
Later additions, once there is enough real contract surface:
|
||||
|
||||
- `src/api/client.ts`
|
||||
- runtime composition in `src/index.ts`
|
||||
|
||||
Suggested initial exports:
|
||||
|
||||
- `api`
|
||||
- `openapi`
|
||||
- `questionApi`
|
||||
- `makeQuestionHandler`
|
||||
|
||||
Phase 1 responsibilities:
|
||||
|
||||
- own pure API contracts
|
||||
- own handler factories for migrated slices
|
||||
- own contract-generated OpenAPI
|
||||
- expose host adapters needed by `packages/opencode`
|
||||
|
||||
Phase 1 non-goals:
|
||||
|
||||
- do not own `listen()`
|
||||
- do not own adapter selection
|
||||
- do not own global server middleware
|
||||
- do not own websocket or SSE transport
|
||||
- do not own process bootstrapping for CLI entrypoints
|
||||
|
||||
## Current source inventory
|
||||
|
||||
These files matter for the first phase.
|
||||
|
||||
Current host and route composition:
|
||||
|
||||
- `src/server/server.ts`
|
||||
- `src/server/control/index.ts`
|
||||
- `src/server/instance/index.ts`
|
||||
- `src/server/middleware.ts`
|
||||
- `src/server/adapter.bun.ts`
|
||||
- `src/server/adapter.node.ts`
|
||||
|
||||
Current experimental `HttpApi` slice:
|
||||
|
||||
- `src/server/instance/httpapi/question.ts`
|
||||
- `src/server/instance/httpapi/index.ts`
|
||||
- `src/server/instance/experimental.ts`
|
||||
- `test/server/question-httpapi.test.ts`
|
||||
|
||||
Current OpenAPI flow:
|
||||
|
||||
- `src/server/server.ts` via `Server.openapi()`
|
||||
- `src/cli/cmd/generate.ts`
|
||||
- `packages/sdk/js/script/build.ts`
|
||||
|
||||
Current runtime and service layer:
|
||||
|
||||
- `src/effect/app-runtime.ts`
|
||||
- `src/effect/run-service.ts`
|
||||
|
||||
## Ownership rules
|
||||
|
||||
Move first into `packages/server`:
|
||||
|
||||
- the experimental `question` `HttpApi` slice
|
||||
- future `provider` and `config` JSON read slices
|
||||
- any new `HttpApi` route groups
|
||||
- transport-local OpenAPI generation for migrated routes
|
||||
|
||||
Keep in `packages/opencode` for now:
|
||||
|
||||
- `src/server/server.ts`
|
||||
- `src/server/control/index.ts`
|
||||
- `src/server/instance/*.ts`
|
||||
- `src/server/middleware.ts`
|
||||
- `src/server/adapter.*.ts`
|
||||
- `src/effect/app-runtime.ts`
|
||||
- `src/effect/run-service.ts`
|
||||
- all Effect services until they move to `packages/core`
|
||||
|
||||
## Placeholder schema rule
|
||||
|
||||
`packages/core` is allowed to lag behind.
|
||||
|
||||
Until shared canonical schemas move to `packages/core`:
|
||||
|
||||
- prefer importing existing Effect Schema DTOs from current locations when practical
|
||||
- if a route only needs a transport-local type and moving the canonical schema would create unrelated churn, allow a temporary server-local placeholder schema
|
||||
- if a placeholder is introduced, leave a short note so it does not become permanent
|
||||
|
||||
The default rule from `schema.md` still applies:
|
||||
|
||||
- Effect Schema owns the type
|
||||
- `.zod` is compatibility only
|
||||
- avoid parallel hand-written Zod and Effect definitions for the same migrated route shape
|
||||
|
||||
## Host boundary rule
|
||||
|
||||
Until host ownership moves:
|
||||
|
||||
- auth stays at the outer Hono app level
|
||||
- compression stays at the outer Hono app level
|
||||
- CORS stays at the outer Hono app level
|
||||
- instance and workspace lookup stay at the current middleware layer
|
||||
- `packages/server` handlers should assume the host already provided the right request context
|
||||
- do not redesign host middleware just to land the package split
|
||||
|
||||
This matches the current guidance in `http-api.md`:
|
||||
|
||||
- keep auth outside the first parallel `HttpApi` slices
|
||||
- keep instance lookup outside the first parallel `HttpApi` slices
|
||||
- keep the first migrations transport-focused and semantics-preserving
|
||||
|
||||
## Route selection rules
|
||||
|
||||
Good early migration targets:
|
||||
|
||||
- `question`
|
||||
- `provider` auth read endpoint
|
||||
- `config` providers read endpoint
|
||||
- small read-only instance routes
|
||||
|
||||
Bad early migration targets:
|
||||
|
||||
- `session`
|
||||
- `event`
|
||||
- `pty`
|
||||
- most `global` streaming or process-heavy routes
|
||||
- anything requiring websocket upgrade handling
|
||||
- anything that mixes many mutations and streaming in one file
|
||||
|
||||
## First vertical slice
|
||||
|
||||
The first slice for the package split is the existing experimental `question` group.
|
||||
|
||||
Why `question` first:
|
||||
|
||||
- it already exists as an experimental `HttpApi` slice
|
||||
- it already follows the desired contract and implementation split in one file
|
||||
- it is already mounted through the current Hono host
|
||||
- it already has an end-to-end test
|
||||
- it is JSON-only
|
||||
- it has low blast radius
|
||||
|
||||
Use the first slice to prove:
|
||||
|
||||
- package boundary
|
||||
- contract and implementation split
|
||||
- host mounting from `packages/opencode`
|
||||
- merged OpenAPI output
|
||||
- test ergonomics for future slices
|
||||
|
||||
Do not broaden scope in the first slice.
|
||||
|
||||
## Incremental migration order
|
||||
|
||||
Use small PRs.
|
||||
|
||||
Each PR should be easy to review, easy to revert, and should not mix extraction work with unrelated service refactors.
|
||||
|
||||
### PR 1. Create `packages/server`
|
||||
|
||||
Scope:
|
||||
|
||||
- add the new workspace package
|
||||
- add package manifest and tsconfig
|
||||
- add empty `src/index.ts`, `src/definition/api.ts`, `src/definition/index.ts`, `src/api/index.ts`, `src/openapi.ts`, and supporting scaffolding
|
||||
|
||||
Rules:
|
||||
|
||||
- no production behavior changes
|
||||
- no host server changes yet
|
||||
- no imports from `packages/opencode` inside `packages/server`
|
||||
- prefer `opentunnel`-style naming from the start: `definition` for contracts, `api` for implementations
|
||||
|
||||
Done means:
|
||||
|
||||
- `packages/server` typechecks
|
||||
- the workspace can import it
|
||||
- the package boundary is in place for follow-up PRs
|
||||
|
||||
### PR 2. Move the experimental question contract
|
||||
|
||||
Scope:
|
||||
|
||||
- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
|
||||
- place it in `packages/server/src/definition/question.ts`
|
||||
- aggregate it in `packages/server/src/definition/api.ts`
|
||||
- generate OpenAPI in `packages/server/src/openapi.ts`
|
||||
|
||||
Rules:
|
||||
|
||||
- contract only in this PR
|
||||
- no handler movement yet if that keeps the diff simpler
|
||||
- keep operation ids and docs metadata stable
|
||||
|
||||
Done means:
|
||||
|
||||
- question contract lives in `packages/server`
|
||||
- OpenAPI can be generated from contract alone
|
||||
- no runtime behavior changes yet
|
||||
|
||||
### PR 3. Move the experimental question handler factory
|
||||
|
||||
Scope:
|
||||
|
||||
- extract the question `HttpApiBuilder.group(...)` implementation into `packages/server/src/api/question.ts`
|
||||
- expose it as a factory that accepts host-provided dependencies or wiring
|
||||
- add a small Hono bridge in `packages/server/src/bridge/hono.ts` if needed
|
||||
|
||||
Rules:
|
||||
|
||||
- `packages/server` must still not import from `packages/opencode`
|
||||
- handler code should stay thin and service-delegating
|
||||
- do not redesign the question service itself in this PR
|
||||
|
||||
Done means:
|
||||
|
||||
- `packages/server` can produce the experimental question handler
|
||||
- the package still stays cycle-free
|
||||
|
||||
### PR 4. Mount `packages/server` question from `packages/opencode`
|
||||
|
||||
Scope:
|
||||
|
||||
- replace local experimental question route wiring in `packages/opencode`
|
||||
- keep the same mount path:
|
||||
- `/experimental/httpapi/question`
|
||||
- `/experimental/httpapi/question/doc`
|
||||
|
||||
Rules:
|
||||
|
||||
- no behavior change
|
||||
- preserve existing docs path
|
||||
- preserve current request and response shapes
|
||||
|
||||
Done means:
|
||||
|
||||
- existing question `HttpApi` test still passes
|
||||
- runtime behavior is unchanged
|
||||
- the current host server is now consuming `packages/server`
|
||||
|
||||
### PR 5. Merge legacy and contract OpenAPI
|
||||
|
||||
Scope:
|
||||
|
||||
- keep `Server.openapi()` as the temporary spec entrypoint
|
||||
- generate legacy Hono spec
|
||||
- generate `packages/server` contract spec
|
||||
- merge them into one document
|
||||
- keep `cli/cmd/generate.ts` and `packages/sdk/js/script/build.ts` consuming one spec
|
||||
|
||||
Rules:
|
||||
|
||||
- fail loudly on duplicate `path + method`
|
||||
- fail loudly on duplicate `operationId`
|
||||
- do not silently overwrite one source with the other
|
||||
|
||||
Done means:
|
||||
|
||||
- one merged spec is produced
|
||||
- migrated question paths can come from `packages/server`
|
||||
- existing SDK generation path still works
|
||||
|
||||
### PR 6. Add merged OpenAPI coverage
|
||||
|
||||
Scope:
|
||||
|
||||
- add one test for merged OpenAPI
|
||||
- assert both a legacy Hono route and a migrated `HttpApi` route exist
|
||||
|
||||
Rules:
|
||||
|
||||
- test the merged document, not just the `packages/server` contract spec in isolation
|
||||
- pick one stable legacy route and one stable migrated route
|
||||
|
||||
Done means:
|
||||
|
||||
- the merged-spec path is covered
|
||||
- future route migrations have a guardrail
|
||||
|
||||
### PR 7. Migrate `GET /provider/auth`
|
||||
|
||||
Scope:
|
||||
|
||||
- add `GET /provider/auth` as the next `HttpApi` slice in `packages/server`
|
||||
- mount it in parallel from `packages/opencode`
|
||||
|
||||
Why this route:
|
||||
|
||||
- JSON-only
|
||||
- simple service delegation
|
||||
- small response shape
|
||||
- already listed as the best next `provider` candidate in `http-api.md`
|
||||
|
||||
Done means:
|
||||
|
||||
- route works through the current host
|
||||
- route appears in merged OpenAPI
|
||||
- no semantic change to provider auth behavior
|
||||
|
||||
### PR 8. Migrate `GET /config/providers`
|
||||
|
||||
Scope:
|
||||
|
||||
- add `GET /config/providers` as a `HttpApi` slice in `packages/server`
|
||||
- mount it in parallel from `packages/opencode`
|
||||
|
||||
Why this route:
|
||||
|
||||
- JSON-only
|
||||
- read-only
|
||||
- low transport complexity
|
||||
- already listed as the best next `config` candidate in `http-api.md`
|
||||
|
||||
Done means:
|
||||
|
||||
- route works unchanged
|
||||
- route appears in merged OpenAPI
|
||||
|
||||
### PR 9+. Migrate small read-only instance routes
|
||||
|
||||
Candidate order:
|
||||
|
||||
1. `GET /path`
|
||||
2. `GET /vcs`
|
||||
3. `GET /vcs/diff`
|
||||
4. `GET /command`
|
||||
5. `GET /agent`
|
||||
6. `GET /skill`
|
||||
|
||||
Rules:
|
||||
|
||||
- one or two endpoints per PR
|
||||
- prefer read-only routes first
|
||||
- keep outer middleware unchanged
|
||||
- keep business logic in the existing service layer
|
||||
|
||||
Done means for each PR:
|
||||
|
||||
- contract lives in `packages/server`
|
||||
- handler lives in `packages/server`
|
||||
- route is mounted from the current host
|
||||
- route appears in merged OpenAPI
|
||||
- behavior remains unchanged
|
||||
|
||||
### Later PR. Move host ownership into `packages/server`
|
||||
|
||||
Only start this after there is enough `packages/core` surface to depend on directly.
|
||||
|
||||
Scope:
|
||||
|
||||
- move server composition into `packages/server`
|
||||
- add embeddable APIs such as `createServer(...)`, `listen(...)`, or `createApp(...)`
|
||||
- move adapter selection and server startup out of `packages/opencode`
|
||||
|
||||
Rules:
|
||||
|
||||
- do not start this while `packages/server` still depends on `packages/opencode`
|
||||
- do not mix this with route migration PRs
|
||||
|
||||
Done means:
|
||||
|
||||
- `packages/server` can be embedded in another Node app
|
||||
- `packages/cli` can depend on `packages/server`
|
||||
- host logic no longer lives in `packages/opencode`
|
||||
|
||||
## PR sizing rule
|
||||
|
||||
Every migration PR should satisfy all of these:
|
||||
|
||||
- one route group or one to two endpoints
|
||||
- no unrelated service refactor
|
||||
- no auth redesign
|
||||
- no middleware redesign
|
||||
- OpenAPI updated
|
||||
- at least one route test or spec test added or updated
|
||||
|
||||
## Done means for a migrated route group
|
||||
|
||||
A route group migration is complete only when:
|
||||
|
||||
1. the `HttpApi` contract lives in `packages/server`
|
||||
2. handler implementation lives in `packages/server`
|
||||
3. the route is mounted from the current host in `packages/opencode`
|
||||
4. the route appears in merged OpenAPI
|
||||
5. request and response schemas are Effect Schema-first or clearly temporary placeholders
|
||||
6. existing behavior remains unchanged
|
||||
7. the route has straightforward test coverage
|
||||
|
||||
## Validation expectations
|
||||
|
||||
For package-split PRs, validate the smallest useful thing.
|
||||
|
||||
Typical validation for the first waves:
|
||||
|
||||
- `bun typecheck` in the touched package directory or directories
|
||||
- the relevant route test, especially `test/server/question-httpapi.test.ts`
|
||||
- merged OpenAPI coverage if the PR touches spec generation
|
||||
|
||||
Do not run tests from repo root.
|
||||
|
||||
## Main risks
|
||||
|
||||
### Package cycle
|
||||
|
||||
This is the biggest risk.
|
||||
|
||||
Bad state:
|
||||
|
||||
- `packages/server` imports services or runtime from `packages/opencode`
|
||||
- `packages/opencode` imports route definitions or handlers from `packages/server`
|
||||
|
||||
Avoid by:
|
||||
|
||||
- keeping phase-1 `packages/server` free of `packages/opencode` imports
|
||||
- using factories and host-provided wiring instead of direct service imports
|
||||
|
||||
### Spec drift
|
||||
|
||||
During the transition there are two route-definition sources.
|
||||
|
||||
Avoid by:
|
||||
|
||||
- one merged spec
|
||||
- collision checks
|
||||
- explicit `operationId`s
|
||||
- merged OpenAPI tests
|
||||
|
||||
### Middleware mismatch
|
||||
|
||||
Current auth, compression, CORS, and instance selection are Hono-centered.
|
||||
|
||||
Avoid by:
|
||||
|
||||
- leaving them where they are during the first wave
|
||||
- not trying to solve `HttpApiMiddleware.Service` globally in the package-split PRs
|
||||
|
||||
### Core lag
|
||||
|
||||
`packages/core` will not be ready everywhere.
|
||||
|
||||
Avoid by:
|
||||
|
||||
- allowing small transport-local placeholder schemas where necessary
|
||||
- keeping those placeholders clearly temporary
|
||||
- not blocking the server extraction on full schema movement
|
||||
|
||||
### Scope creep
|
||||
|
||||
The first vertical slice is easy to overload.
|
||||
|
||||
Avoid by:
|
||||
|
||||
- proving the package boundary first
|
||||
- not mixing package creation, route migration, host redesign, and core extraction in the same change
|
||||
|
||||
## Non-goals for the first wave
|
||||
|
||||
- do not replace all Hono routes at once
|
||||
- do not migrate SSE or websocket routes first
|
||||
- do not redesign auth
|
||||
- do not redesign instance lookup
|
||||
- do not wait for full `packages/core` before starting `packages/server`
|
||||
- do not change SDK generation to consume multiple specs
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] create `packages/server`
|
||||
- [x] add package-level exports for contract and OpenAPI
|
||||
- [ ] extract `question` contract into `packages/server`
|
||||
- [ ] extract `question` handler factory into `packages/server`
|
||||
- [ ] mount `question` from `packages/opencode`
|
||||
- [ ] merge legacy and contract OpenAPI into one document
|
||||
- [ ] add merged-spec coverage
|
||||
- [ ] migrate `GET /provider/auth`
|
||||
- [ ] migrate `GET /config/providers`
|
||||
- [ ] migrate small read-only instance routes one or two at a time
|
||||
- [ ] move host ownership into `packages/server` only after `packages/core` is ready enough
|
||||
- [ ] split `packages/cli` after server and core boundaries are stable
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
The fastest correct path is:
|
||||
|
||||
1. establish `packages/server` as the contract-first boundary
|
||||
2. keep `packages/opencode` as the temporary host
|
||||
3. migrate a few safe JSON routes
|
||||
4. keep one merged OpenAPI document
|
||||
5. move actual host ownership only after `packages/core` can support it cleanly
|
||||
|
||||
If a proposed PR would make `packages/server` import from `packages/opencode`, stop and restructure the boundary first.
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { Session } from "../../session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionID } from "../../session/schema"
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
@@ -7,16 +8,231 @@ import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function redact(kind: string, id: string, value: string) {
|
||||
return value.trim() ? `[redacted:${kind}:${id}]` : value
|
||||
}
|
||||
|
||||
function data(kind: string, id: string, value: Record<string, unknown> | undefined) {
|
||||
if (!value) return value
|
||||
return Object.keys(value).length ? { redacted: `${kind}:${id}` } : value
|
||||
}
|
||||
|
||||
function span(id: string, value: { value: string; start: number; end: number }) {
|
||||
return {
|
||||
...value,
|
||||
value: redact("file-text", id, value.value),
|
||||
}
|
||||
}
|
||||
|
||||
function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) {
|
||||
return diffs?.map((item, i) => ({
|
||||
...item,
|
||||
file: redact(`${kind}-file`, String(i), item.file),
|
||||
patch: redact(`${kind}-patch`, String(i), item.patch),
|
||||
}))
|
||||
}
|
||||
|
||||
function source(part: MessageV2.FilePart) {
|
||||
if (!part.source) return part.source
|
||||
if (part.source.type === "symbol") {
|
||||
return {
|
||||
...part.source,
|
||||
path: redact("file-path", part.id, part.source.path),
|
||||
name: redact("file-symbol", part.id, part.source.name),
|
||||
text: span(part.id, part.source.text),
|
||||
}
|
||||
}
|
||||
if (part.source.type === "resource") {
|
||||
return {
|
||||
...part.source,
|
||||
clientName: redact("file-client", part.id, part.source.clientName),
|
||||
uri: redact("file-uri", part.id, part.source.uri),
|
||||
text: span(part.id, part.source.text),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...part.source,
|
||||
path: redact("file-path", part.id, part.source.path),
|
||||
text: span(part.id, part.source.text),
|
||||
}
|
||||
}
|
||||
|
||||
function filepart(part: MessageV2.FilePart): MessageV2.FilePart {
|
||||
return {
|
||||
...part,
|
||||
url: redact("file-url", part.id, part.url),
|
||||
filename: part.filename === undefined ? undefined : redact("file-name", part.id, part.filename),
|
||||
source: source(part),
|
||||
}
|
||||
}
|
||||
|
||||
function part(part: MessageV2.Part): MessageV2.Part {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return {
|
||||
...part,
|
||||
text: redact("text", part.id, part.text),
|
||||
metadata: data("text-metadata", part.id, part.metadata),
|
||||
}
|
||||
case "reasoning":
|
||||
return {
|
||||
...part,
|
||||
text: redact("reasoning", part.id, part.text),
|
||||
metadata: data("reasoning-metadata", part.id, part.metadata),
|
||||
}
|
||||
case "file":
|
||||
return filepart(part)
|
||||
case "subtask":
|
||||
return {
|
||||
...part,
|
||||
prompt: redact("subtask-prompt", part.id, part.prompt),
|
||||
description: redact("subtask-description", part.id, part.description),
|
||||
command: part.command === undefined ? undefined : redact("subtask-command", part.id, part.command),
|
||||
}
|
||||
case "tool":
|
||||
return {
|
||||
...part,
|
||||
metadata: data("tool-metadata", part.id, part.metadata),
|
||||
state:
|
||||
part.state.status === "pending"
|
||||
? {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
raw: redact("tool-raw", part.id, part.state.raw),
|
||||
}
|
||||
: part.state.status === "running"
|
||||
? {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
title: part.state.title === undefined ? undefined : redact("tool-title", part.id, part.state.title),
|
||||
metadata: data("tool-state-metadata", part.id, part.state.metadata),
|
||||
}
|
||||
: part.state.status === "completed"
|
||||
? {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
output: redact("tool-output", part.id, part.state.output),
|
||||
title: redact("tool-title", part.id, part.state.title),
|
||||
metadata: data("tool-state-metadata", part.id, part.state.metadata) ?? part.state.metadata,
|
||||
attachments: part.state.attachments?.map(filepart),
|
||||
}
|
||||
: {
|
||||
...part.state,
|
||||
input: data("tool-input", part.id, part.state.input) ?? part.state.input,
|
||||
metadata: data("tool-state-metadata", part.id, part.state.metadata),
|
||||
},
|
||||
}
|
||||
case "patch":
|
||||
return {
|
||||
...part,
|
||||
hash: redact("patch", part.id, part.hash),
|
||||
files: part.files.map((item: string, i: number) => redact("patch-file", `${part.id}-${i}`, item)),
|
||||
}
|
||||
case "snapshot":
|
||||
return {
|
||||
...part,
|
||||
snapshot: redact("snapshot", part.id, part.snapshot),
|
||||
}
|
||||
case "step-start":
|
||||
return {
|
||||
...part,
|
||||
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
|
||||
}
|
||||
case "step-finish":
|
||||
return {
|
||||
...part,
|
||||
snapshot: part.snapshot === undefined ? undefined : redact("snapshot", part.id, part.snapshot),
|
||||
}
|
||||
case "agent":
|
||||
return {
|
||||
...part,
|
||||
source: !part.source
|
||||
? part.source
|
||||
: {
|
||||
...part.source,
|
||||
value: redact("agent-source", part.id, part.source.value),
|
||||
},
|
||||
}
|
||||
default:
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
const partFn = part
|
||||
|
||||
function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) {
|
||||
return {
|
||||
info: {
|
||||
...data.info,
|
||||
title: redact("session-title", data.info.id, data.info.title),
|
||||
directory: redact("session-directory", data.info.id, data.info.directory),
|
||||
summary: !data.info.summary
|
||||
? data.info.summary
|
||||
: {
|
||||
...data.info.summary,
|
||||
diffs: diff("session-diff", data.info.summary.diffs),
|
||||
},
|
||||
revert: !data.info.revert
|
||||
? data.info.revert
|
||||
: {
|
||||
...data.info.revert,
|
||||
snapshot:
|
||||
data.info.revert.snapshot === undefined
|
||||
? undefined
|
||||
: redact("revert-snapshot", data.info.id, data.info.revert.snapshot),
|
||||
diff:
|
||||
data.info.revert.diff === undefined
|
||||
? undefined
|
||||
: redact("revert-diff", data.info.id, data.info.revert.diff),
|
||||
},
|
||||
},
|
||||
messages: data.messages.map((msg) => ({
|
||||
info:
|
||||
msg.info.role === "user"
|
||||
? {
|
||||
...msg.info,
|
||||
system: msg.info.system === undefined ? undefined : redact("system", msg.info.id, msg.info.system),
|
||||
summary: !msg.info.summary
|
||||
? msg.info.summary
|
||||
: {
|
||||
...msg.info.summary,
|
||||
title:
|
||||
msg.info.summary.title === undefined
|
||||
? undefined
|
||||
: redact("summary-title", msg.info.id, msg.info.summary.title),
|
||||
body:
|
||||
msg.info.summary.body === undefined
|
||||
? undefined
|
||||
: redact("summary-body", msg.info.id, msg.info.summary.body),
|
||||
diffs: diff("message-diff", msg.info.summary.diffs),
|
||||
},
|
||||
}
|
||||
: {
|
||||
...msg.info,
|
||||
path: {
|
||||
cwd: redact("cwd", msg.info.id, msg.info.path.cwd),
|
||||
root: redact("root", msg.info.id, msg.info.path.root),
|
||||
},
|
||||
},
|
||||
parts: msg.parts.map(partFn),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const ExportCommand = cmd({
|
||||
command: "export [sessionID]",
|
||||
describe: "export session data as JSON",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs.positional("sessionID", {
|
||||
describe: "session id to export",
|
||||
type: "string",
|
||||
})
|
||||
return yargs
|
||||
.positional("sessionID", {
|
||||
describe: "session id to export",
|
||||
type: "string",
|
||||
})
|
||||
.option("sanitize", {
|
||||
describe: "redact sensitive transcript and file data",
|
||||
type: "boolean",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
@@ -69,26 +285,17 @@ export const ExportCommand = cmd({
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionInfo, messages } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const sessionInfo = yield* session.get(sessionID!)
|
||||
return {
|
||||
sessionInfo,
|
||||
messages: yield* session.messages({ sessionID: sessionInfo.id }),
|
||||
}
|
||||
}),
|
||||
const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!)))
|
||||
const messages = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })),
|
||||
)
|
||||
|
||||
const exportData = {
|
||||
info: sessionInfo,
|
||||
messages: messages.map((msg) => ({
|
||||
info: msg.info,
|
||||
parts: msg.parts,
|
||||
})),
|
||||
messages,
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(exportData, null, 2))
|
||||
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
} catch (error) {
|
||||
UI.error(`Session not found: ${sessionID!}`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { memoMap } from "./run-service"
|
||||
import { attach, memoMap } from "./run-service"
|
||||
import { Observability } from "./oltp"
|
||||
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
@@ -49,7 +49,7 @@ import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
// Observability.layer,
|
||||
Observability.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
@@ -95,6 +95,27 @@ export const AppLayer = Layer.mergeAll(
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
).pipe(Layer.provide(Observability.layer))
|
||||
)
|
||||
|
||||
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
const rt = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
|
||||
const wrap = (effect: Parameters<typeof rt.runSync>[0]) => attach(effect as never) as never
|
||||
|
||||
export const AppRuntime: Runtime = {
|
||||
runSync(effect) {
|
||||
return rt.runSync(wrap(effect))
|
||||
},
|
||||
runPromise(effect, options) {
|
||||
return rt.runPromise(wrap(effect), options)
|
||||
},
|
||||
runPromiseExit(effect, options) {
|
||||
return rt.runPromiseExit(wrap(effect), options)
|
||||
},
|
||||
runFork(effect) {
|
||||
return rt.runFork(wrap(effect))
|
||||
},
|
||||
runCallback(effect) {
|
||||
return rt.runCallback(wrap(effect))
|
||||
},
|
||||
dispose: () => rt.dispose(),
|
||||
}
|
||||
|
||||
@@ -3,88 +3,33 @@ import { memoMap } from "@/effect/run-service"
|
||||
import { Question } from "@/question"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeQuestionHandler, questionApi } from "@opencode-ai/server"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import type { Handler } from "hono"
|
||||
|
||||
const root = "/experimental/httpapi/question"
|
||||
const Reply = Schema.Struct({
|
||||
answers: Schema.Array(Question.Answer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
})
|
||||
|
||||
const Api = HttpApi.make("question")
|
||||
.add(
|
||||
HttpApiGroup.make("question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: Schema.Array(Question.Request),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.list",
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: QuestionID },
|
||||
payload: Reply,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.reply",
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "question",
|
||||
description: "Experimental HttpApi question routes.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
const QuestionLive = HttpApiBuilder.group(
|
||||
Api,
|
||||
"question",
|
||||
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
|
||||
const QuestionLive = makeQuestionHandler({
|
||||
list: Effect.fn("QuestionHttpApi.host.list")(function* () {
|
||||
const svc = yield* Question.Service
|
||||
|
||||
const list = Effect.fn("QuestionHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
|
||||
params: { requestID: QuestionID }
|
||||
payload: Schema.Schema.Type<typeof Reply>
|
||||
}) {
|
||||
yield* svc.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
answers: ctx.payload.answers,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply)
|
||||
return yield* svc.list()
|
||||
}),
|
||||
).pipe(Layer.provide(Question.defaultLayer))
|
||||
reply: Effect.fn("QuestionHttpApi.host.reply")(function* (input) {
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reply({
|
||||
requestID: QuestionID.make(input.requestID),
|
||||
answers: input.answers,
|
||||
})
|
||||
}),
|
||||
}).pipe(Layer.provide(Question.defaultLayer))
|
||||
|
||||
const web = lazy(() =>
|
||||
HttpRouter.toWebHandler(
|
||||
Layer.mergeAll(
|
||||
AppLayer,
|
||||
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
|
||||
HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe(
|
||||
Layer.provide(QuestionLive),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
|
||||
@@ -205,7 +205,11 @@ export namespace LLM {
|
||||
// calls but no tools param is present. When there are no active tools (e.g.
|
||||
// during compaction), inject a stub tool to satisfy the validation requirement.
|
||||
// The stub description explicitly tells the model not to call it.
|
||||
if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
|
||||
if (
|
||||
(isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
|
||||
Object.keys(tools).length === 0 &&
|
||||
hasToolCalls(input.messages)
|
||||
) {
|
||||
tools["_noop"] = tool({
|
||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||
inputSchema: jsonSchema({
|
||||
|
||||
@@ -104,12 +104,21 @@ export namespace SessionPrompt {
|
||||
const summary = yield* SessionSummary.Service
|
||||
const sys = yield* SystemPrompt.Service
|
||||
const llm = yield* LLM.Service
|
||||
|
||||
const run = {
|
||||
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))),
|
||||
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))),
|
||||
}
|
||||
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
||||
const ctx = yield* Effect.context()
|
||||
return {
|
||||
promise: <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(ctx)(effect),
|
||||
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runForkWith(ctx)(effect),
|
||||
}
|
||||
})
|
||||
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
||||
const run = yield* runner()
|
||||
return {
|
||||
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
|
||||
resolvePromptParts: (template: string) => resolvePromptParts(template),
|
||||
prompt: (input: PromptInput) => prompt(input),
|
||||
} satisfies TaskPromptOps
|
||||
})
|
||||
|
||||
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
||||
yield* elog.info("cancel", { sessionID })
|
||||
@@ -359,6 +368,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
const run = yield* runner()
|
||||
const promptOps = yield* ops()
|
||||
|
||||
const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
|
||||
sessionID: input.session.id,
|
||||
@@ -528,6 +539,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}) {
|
||||
const { task, model, lastUser, sessionID, session, msgs } = input
|
||||
const ctx = yield* InstanceState.context
|
||||
const promptOps = yield* ops()
|
||||
const { task: taskTool } = yield* registry.named()
|
||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||
@@ -712,6 +724,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const run = yield* runner()
|
||||
const session = yield* sessions.get(input.sessionID)
|
||||
if (session.revert) {
|
||||
yield* revert.cleanup(session)
|
||||
@@ -1659,12 +1672,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return result
|
||||
})
|
||||
|
||||
const promptOps: TaskPromptOps = {
|
||||
cancel: (sessionID) => run.fork(cancel(sessionID)),
|
||||
resolvePromptParts: (template) => resolvePromptParts(template),
|
||||
prompt: (input) => prompt(input),
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
cancel,
|
||||
prompt,
|
||||
|
||||
61
packages/opencode/test/effect/app-runtime-logger.test.ts
Normal file
61
packages/opencode/test/effect/app-runtime-logger.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Context, Effect, Layer, Logger } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { EffectLogger } from "../../src/effect/logger"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
function check(loggers: ReadonlySet<Logger.Logger<unknown, any>>) {
|
||||
return {
|
||||
defaultLogger: loggers.has(Logger.defaultLogger),
|
||||
tracerLogger: loggers.has(Logger.tracerLogger),
|
||||
effectLogger: loggers.has(EffectLogger.logger),
|
||||
size: loggers.size,
|
||||
}
|
||||
}
|
||||
|
||||
test("makeRuntime installs EffectLogger through Observability.layer", async () => {
|
||||
class Dummy extends Context.Service<Dummy, { readonly current: () => Effect.Effect<ReturnType<typeof check>> }>()(
|
||||
"@test/Dummy",
|
||||
) {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Dummy,
|
||||
Effect.gen(function* () {
|
||||
return Dummy.of({
|
||||
current: () => Effect.map(Effect.service(Logger.CurrentLoggers), check),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const rt = makeRuntime(Dummy, layer)
|
||||
const current = await rt.runPromise((svc) => svc.current())
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
expect(current.defaultLogger).toBe(false)
|
||||
})
|
||||
|
||||
test("AppRuntime also installs EffectLogger through Observability.layer", async () => {
|
||||
const current = await AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check))
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
expect(current.defaultLogger).toBe(false)
|
||||
})
|
||||
|
||||
test("AppRuntime attaches InstanceRef from ALS", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const dir = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return (yield* InstanceRef)?.directory
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
expect(dir).toBe(tmp.path)
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Server } from "../../src/server/server"
|
||||
|
||||
describe("question openapi", () => {
|
||||
test("keeps the public reply body inline", async () => {
|
||||
const spec = await Server.openapi()
|
||||
const body = spec.paths["/question/{requestID}/reply"]?.post?.requestBody
|
||||
|
||||
expect(body).toBeDefined()
|
||||
if (!body || "$ref" in body) throw new Error("expected inline request body")
|
||||
|
||||
const media = body.content["application/json"]
|
||||
expect(media).toBeDefined()
|
||||
if (!media || "$ref" in media) throw new Error("expected inline json media type")
|
||||
|
||||
expect(media.schema).toMatchObject({
|
||||
type: "object",
|
||||
required: ["answers"],
|
||||
properties: {
|
||||
answers: {
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
type: "array",
|
||||
items: {
|
||||
$ref: "#/components/schemas/QuestionAnswer",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -483,6 +483,48 @@ it.live("loop continues when finish is tool-calls", () =>
|
||||
),
|
||||
)
|
||||
|
||||
it.live("glob tool keeps instance context during prompt runs", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({
|
||||
title: "Glob context",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
const file = path.join(dir, "probe.txt")
|
||||
yield* Effect.promise(() => Bun.write(file, "probe"))
|
||||
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "build",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "find text files" }],
|
||||
})
|
||||
yield* llm.tool("glob", { pattern: "**/*.txt" })
|
||||
yield* llm.text("done")
|
||||
|
||||
const result = yield* prompt.loop({ sessionID: session.id })
|
||||
expect(result.info.role).toBe("assistant")
|
||||
|
||||
const msgs = yield* MessageV2.filterCompactedEffect(session.id)
|
||||
const tool = msgs
|
||||
.flatMap((msg) => msg.parts)
|
||||
.find(
|
||||
(part): part is CompletedToolPart =>
|
||||
part.type === "tool" && part.tool === "glob" && part.state.status === "completed",
|
||||
)
|
||||
if (!tool) return
|
||||
|
||||
expect(tool.state.output).toContain(file)
|
||||
expect(tool.state.output).not.toContain("No context found for instance")
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loop continues when finish is stop but assistant has tool parts", () =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
29
packages/server/package.json
Normal file
29
packages/server/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.4.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./openapi": "./src/openapi.ts",
|
||||
"./definition": "./src/definition/index.ts",
|
||||
"./definition/api": "./src/definition/api.ts",
|
||||
"./definition/question": "./src/definition/question.ts",
|
||||
"./api": "./src/api/index.ts",
|
||||
"./api/question": "./src/api/question.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "catalog:"
|
||||
}
|
||||
}
|
||||
2
packages/server/src/api/index.ts
Normal file
2
packages/server/src/api/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { makeQuestionHandler } from "./question.js"
|
||||
export type { QuestionOps } from "./question.js"
|
||||
37
packages/server/src/api/question.ts
Normal file
37
packages/server/src/api/question.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { QuestionReply, QuestionRequest, questionApi } from "../definition/question.js"
|
||||
|
||||
export interface QuestionOps<R = never> {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<unknown>, never, R>
|
||||
readonly reply: (input: {
|
||||
requestID: string
|
||||
answers: Schema.Schema.Type<typeof QuestionReply>["answers"]
|
||||
}) => Effect.Effect<void, never, R>
|
||||
}
|
||||
|
||||
export const makeQuestionHandler = <R>(ops: QuestionOps<R>) =>
|
||||
HttpApiBuilder.group(
|
||||
questionApi,
|
||||
"question",
|
||||
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
|
||||
const decode = Schema.decodeUnknownSync(Schema.Array(QuestionRequest))
|
||||
|
||||
const list = Effect.fn("QuestionHttpApi.list")(function* () {
|
||||
return decode(yield* ops.list())
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
|
||||
params: { requestID: string }
|
||||
payload: Schema.Schema.Type<typeof QuestionReply>
|
||||
}) {
|
||||
yield* ops.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
answers: ctx.payload.answers,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply)
|
||||
}),
|
||||
)
|
||||
12
packages/server/src/definition/api.ts
Normal file
12
packages/server/src/definition/api.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
|
||||
import { questionApi } from "./question.js"
|
||||
|
||||
export const api = HttpApi.make("opencode")
|
||||
.addHttpApi(questionApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
2
packages/server/src/definition/index.ts
Normal file
2
packages/server/src/definition/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { api } from "./api.js"
|
||||
export { questionApi, QuestionReply, QuestionRequest } from "./question.js"
|
||||
94
packages/server/src/definition/question.ts
Normal file
94
packages/server/src/definition/question.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
const root = "/experimental/httpapi/question"
|
||||
|
||||
// Temporary transport-local schemas until canonical question schemas move into packages/core.
|
||||
export const QuestionID = Schema.String.annotate({ identifier: "QuestionID" })
|
||||
export const SessionID = Schema.String.annotate({ identifier: "SessionID" })
|
||||
export const MessageID = Schema.String.annotate({ identifier: "MessageID" })
|
||||
|
||||
export class QuestionOption extends Schema.Class<QuestionOption>("QuestionOption")({
|
||||
label: Schema.String.annotate({
|
||||
description: "Display text (1-5 words, concise)",
|
||||
}),
|
||||
description: Schema.String.annotate({
|
||||
description: "Explanation of choice",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
const base = {
|
||||
question: Schema.String.annotate({
|
||||
description: "Complete question",
|
||||
}),
|
||||
header: Schema.String.annotate({
|
||||
description: "Very short label (max 30 chars)",
|
||||
}),
|
||||
options: Schema.Array(QuestionOption).annotate({
|
||||
description: "Available choices",
|
||||
}),
|
||||
multiple: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Allow selecting multiple choices",
|
||||
}),
|
||||
}
|
||||
|
||||
export class QuestionInfo extends Schema.Class<QuestionInfo>("QuestionInfo")({
|
||||
...base,
|
||||
custom: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Allow typing a custom answer (default: true)",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class QuestionTool extends Schema.Class<QuestionTool>("QuestionTool")({
|
||||
messageID: MessageID,
|
||||
callID: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class QuestionRequest extends Schema.Class<QuestionRequest>("QuestionRequest")({
|
||||
id: QuestionID,
|
||||
sessionID: SessionID,
|
||||
questions: Schema.Array(QuestionInfo).annotate({
|
||||
description: "Questions to ask",
|
||||
}),
|
||||
tool: Schema.optional(QuestionTool),
|
||||
}) {}
|
||||
|
||||
export const QuestionAnswer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" })
|
||||
|
||||
export class QuestionReply extends Schema.Class<QuestionReply>("QuestionReply")({
|
||||
answers: Schema.Array(QuestionAnswer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export const questionApi = HttpApi.make("question").add(
|
||||
HttpApiGroup.make("question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: Schema.Array(QuestionRequest),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.list",
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: QuestionID },
|
||||
payload: QuestionReply,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.reply",
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "question",
|
||||
description: "Experimental HttpApi question routes.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
6
packages/server/src/index.ts
Normal file
6
packages/server/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { openapi } from "./openapi.js"
|
||||
export { makeQuestionHandler } from "./api/question.js"
|
||||
export { api } from "./definition/api.js"
|
||||
export { questionApi, QuestionReply, QuestionRequest } from "./definition/question.js"
|
||||
export type { OpenApiSpec, ServerApi } from "./types.js"
|
||||
export type { QuestionOps } from "./api/question.js"
|
||||
5
packages/server/src/openapi.ts
Normal file
5
packages/server/src/openapi.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import { api } from "./definition/api.js"
|
||||
import type { OpenApiSpec } from "./types.js"
|
||||
|
||||
export const openapi = (): OpenApiSpec => OpenApi.fromApi(api)
|
||||
5
packages/server/src/types.ts
Normal file
5
packages/server/src/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export type ServerApi = HttpApi.HttpApi<string, HttpApiGroup.Any>
|
||||
|
||||
export type OpenApiSpec = OpenApi.OpenAPISpec
|
||||
15
packages/server/tsconfig.json
Normal file
15
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"module": "nodenext",
|
||||
"declaration": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
await $`bun ./script/generate.ts`
|
||||
await $`git diff --exit-code -- packages/sdk/openapi.json packages/sdk/js/src/gen packages/sdk/js/src/v2`
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.4.3",
|
||||
"version": "1.4.4",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user