Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 792c85da4d fix(core): exclude deleted projects from listing 2026-07-31 11:21:05 -04:00
24 changed files with 140 additions and 240 deletions
+1 -5
View File
@@ -107,7 +107,6 @@ export const bodyFields = {
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
@@ -416,7 +415,6 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
return {
model: request.model.id,
messages: yield* lowerMessages(request),
@@ -429,9 +427,7 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
max_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
frequency_penalty: generation?.frequencyPenalty,
-1
View File
@@ -6,7 +6,6 @@ export interface Settings extends Readonly<Record<string, unknown>> {
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
+48
View File
@@ -28,9 +28,57 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString()
}
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
"anthropic_version",
"content",
"contents",
"frequencyPenalty",
"frequency_penalty",
"generationConfig",
"inferenceConfig",
"input",
"maxTokens",
"max_tokens",
"messages",
"model",
"presencePenalty",
"presence_penalty",
"responseFormat",
"response_format",
"seed",
"stop",
"stopSequences",
"stop_sequences",
"stream",
"streamOptions",
"stream_options",
"system",
"systemInstruction",
"system_instruction",
"temperature",
"thinking",
"toolChoice",
"toolConfig",
"tool_choice",
"tool_config",
"tools",
"topK",
"topP",
"top_k",
"top_p",
])
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
Effect.gen(function* () {
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
if (forbiddenKeys.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
)
if (ProviderShared.isRecord(body)) {
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
-5
View File
@@ -123,7 +123,6 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
@@ -167,13 +166,9 @@ export namespace ModelDefaults {
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility),
}) {}
export namespace ModelCompatibility {
+18 -21
View File
@@ -171,27 +171,24 @@ describe("request option precedence", () => {
),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const error = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(Effect.flip)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
})
}),
)
it.effect("uses model output limits after route limits and before call maxTokens", () =>
@@ -181,6 +181,23 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
http: { body: { anthropic_version: "wrong" } },
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(Effect.flip)
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
}),
)
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -144,20 +144,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("configures the max tokens request field", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
const prepared = yield* compileRequest(
LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
)
expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
expect(prepared.body).not.toHaveProperty("max_tokens")
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -38,19 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 79_400
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 0
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
+2 -3
View File
@@ -58,9 +58,8 @@ export async function collectNodeAssets(target: NodeTarget) {
source: path.join(ptyRoot, relative),
})),
]
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()]
await Promise.all(unique.map((asset) => stat(asset.source)))
return unique
await Promise.all(assets.map((asset) => stat(asset.source)))
return assets
}
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
-17
View File
@@ -1,17 +0,0 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
import { collectNodeAssets } from "../script/node-assets"
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
test("collects each SEA asset key once", async () => {
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
},
])
})
+1 -1
View File
@@ -332,7 +332,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
body: projected.body === undefined ? undefined : { ...projected.body },
headers: info.headers,
},
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
limits: { context: info.limit.context, output: info.limit.output },
providerOptions,
},
body: {
+2 -2
View File
@@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) =>
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: Info) => {
@@ -204,7 +204,7 @@ export const fromCatalogModel = (
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
+6 -2
View File
@@ -220,8 +220,12 @@ export const OpenAIPlugin = define({
return
}
draft.cost = []
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
if (draft.id.includes("gpt-5.5")) {
draft.limit = { context: 400_000, input: 272_000, output: 128_000 }
}
if (draft.id.includes("gpt-5.6")) {
draft.limit = { context: 500_000, input: 372_000, output: 128_000 }
}
})
}
})
+1 -1
View File
@@ -148,7 +148,7 @@ const layer = Layer.effect(
.orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
return yield* Effect.filter(rows.map(fromRow), (project) => fs.existsSafe(project.canonical))
})
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
+10 -12
View File
@@ -152,11 +152,14 @@ const settings = (documents: readonly Config.Entry[]) => {
const configured = documents
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return {
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
}
return configured.reduce<Settings>(
(result, current) => ({
auto: current.auto ?? result.auto,
buffer: current.buffer ?? result.buffer,
tokens: current.keep?.tokens ?? result.tokens,
}),
{ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
)
}
const select = (
@@ -347,16 +350,11 @@ const make = (dependencies: Dependencies) => {
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const limits = input.model.route.defaults.limits
const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer,
context - Math.max(output, config.buffer),
)
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const used =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (used <= 0) return false
return used >= promptCeiling
return used >= context - (output || config.buffer)
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
+2 -4
View File
@@ -17,7 +17,6 @@ interface ModelOptions {
readonly headers?: Info["headers"]
readonly body?: Info["body"]
readonly variants?: Info["variants"]
readonly limit?: Info["limit"]
}
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
@@ -37,7 +36,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
cost: [],
status: "active",
enabled: true,
limit: options.limit ?? { context: 100, output: 20 },
limit: { context: 100, output: 20 },
})
describe("ModelResolver", () => {
@@ -45,7 +44,6 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
limit: { context: 100, input: 80, output: 20 },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
@@ -57,7 +55,7 @@ describe("ModelResolver", () => {
endpoint: { baseURL: "https://openai.example/v1" },
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, input: 80, output: 20 },
limits: { context: 100, output: 20 },
http: { body: { custom_extension: { enabled: true } } },
},
})
@@ -74,9 +74,6 @@ describe("OpenAIPlugin", () => {
]
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.4"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 64_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => {
model.modelID = Model.ID.make("gpt-5.4")
model.body = { reasoning: { mode: "pro" } }
@@ -140,7 +137,7 @@ describe("OpenAIPlugin", () => {
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -148,15 +145,10 @@ describe("OpenAIPlugin", () => {
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 272_000,
input: 272_000,
output: 64_000,
})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(gpt56.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
+22 -7
View File
@@ -15,8 +15,16 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Layer.merge(AppNodeBuilder.build(Project.node), AppNodeBuilder.build(Database.node)))
describe("Project.list", () => {
it.effect("returns complete projects ordered by recent update", () =>
it.live("returns existing projects ordered by recent update", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const older = abs(path.join(tmp.path, "older"))
const newer = abs(path.join(tmp.path, "newer"))
const deleted = abs(path.join(tmp.path, "deleted"))
yield* Effect.promise(() => Promise.all([fs.mkdir(older), fs.mkdir(newer)]))
const db = (yield* Database.Service).db
const project = yield* Project.Service
yield* db
@@ -24,42 +32,49 @@ describe("Project.list", () => {
.values([
{
id: Project.ID.make("older"),
worktree: abs("/older"),
worktree: older,
vcs: "git",
name: "Older",
icon_color: "#000000",
commands: { start: "bun dev" },
sandboxes: [abs("/older/sandbox")],
sandboxes: [abs(path.join(older, "sandbox"))],
time_created: 1,
time_updated: 1,
},
{
id: Project.ID.make("newer"),
worktree: abs("/newer"),
worktree: newer,
sandboxes: [],
time_created: 2,
time_updated: 2,
time_initialized: 3,
},
{
id: Project.ID.make("deleted"),
worktree: deleted,
sandboxes: [],
time_created: 3,
time_updated: 3,
},
])
.run()
expect(yield* project.list()).toEqual([
{
id: Project.ID.make("newer"),
canonical: abs("/newer"),
canonical: newer,
time: { created: 2, updated: 2, initialized: 3 },
sandboxes: [],
},
{
id: Project.ID.make("older"),
canonical: abs("/older"),
canonical: older,
vcs: "git",
name: "Older",
icon: { color: "#000000" },
commands: { start: "bun dev" },
time: { created: 1, updated: 1 },
sandboxes: [abs("/older/sandbox")],
sandboxes: [abs(path.join(older, "sandbox"))],
},
])
}),
+1 -49
View File
@@ -19,11 +19,9 @@ import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -132,52 +130,6 @@ test("compaction prompt requires the checkpoint headings in order", () => {
expect(prompt).toContain("Keep every section, even when empty.")
})
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
id: Session.ID.make("ses_input_limit"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({
session,
model: Model.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits }),
}),
cost: [],
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
expect(compaction.required(input(80_000, contextLimited))).toBe(true)
const outputLimited = { context: 100_000, output: 30_000 }
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
}),
)
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
-15
View File
@@ -26,27 +26,12 @@ import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
export interface Storage {
/**
* Durable JSON state: persisted to disk, survives hot reloads and TUI
* restarts, and stays live-synced across running TUI instances.
*/
store<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-memory state: survives plugin hot reloads (old and new
* generations share the same live store) and is gone when the TUI exits.
* Updates are synchronous and values need not be JSON-serializable.
*/
memory<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
}
interface LocationCollection<Value> {
+1 -18
View File
@@ -1,5 +1,5 @@
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
import { createStore, produce, reconcile, type Store } from "solid-js/store"
import { createStore, reconcile, type Store } from "solid-js/store"
import path from "path"
import { mkdirSync, readFileSync, watch } from "fs"
import { Flock } from "@opencode-ai/util/flock"
@@ -13,20 +13,12 @@ type Options<Value extends object> = {
}
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
type MemoryEntry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
export interface Storage {
store<Value extends object>(
key: string,
options: Options<Value>,
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-process state. Entries are memoized here, above consumer
* lifecycles, so the same live store survives plugin hot reloads; it is
* gone when the TUI exits. Updates are synchronous and values need not be
* JSON-serializable.
*/
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
}
function clone<Value extends object>(value: Value) {
@@ -45,7 +37,6 @@ function segment(value: string) {
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
@@ -82,14 +73,6 @@ function createStorage(root: string, channel: string) {
entries.set(file, { value: entry as Entry<object>, reload })
return entry
},
memory<Value extends object>(key: string, options: { readonly initial: Value }) {
const existing = memories.get(key)
if (existing) return existing as MemoryEntry<Value>
const [store, setStore] = createStore(options.initial)
const entry = [store, (mutation: (draft: Value) => void) => setStore(produce(mutation))] as const
memories.set(key, entry as MemoryEntry<object>)
return entry
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
-1
View File
@@ -240,7 +240,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
storage: {
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
memory: (key, options) => storage.memory(`plugin.${item.plugin.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
@@ -1,44 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { mkdtempSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { TuiAppProvider } from "../../src/context/runtime"
import { StorageProvider, useStorage, type Storage } from "../../src/context/storage"
import { TestTuiContexts } from "../fixture/tui-environment"
test("memory storage is synchronous, keyed, and stable across lookups", async () => {
let storage!: Storage
function Probe() {
storage = useStorage()
return <box />
}
await testRender(() => (
<TestTuiContexts paths={{ state: mkdtempSync(path.join(tmpdir(), "opencode-storage-test-")) }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<Probe />
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
))
const [state, update] = storage.memory("tick", { initial: { count: 0, at: undefined as Date | undefined } })
// Synchronous update, no JSON round-trip: a Date survives as-is.
const now = new Date()
update((draft) => {
draft.count += 1
draft.at = now
})
expect(state.count).toBe(1)
expect(state.at).toBe(now)
// Same key returns the same live store (what hot-reload survival relies
// on); a different key is isolated.
const [again] = storage.memory("tick", { initial: { count: 99, at: undefined as Date | undefined } })
expect(again).toBe(state)
expect(again.count).toBe(1)
const [other] = storage.memory("other", { initial: { count: 0 } })
expect(other.count).toBe(0)
})
@@ -95,7 +95,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `8000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
| `buffer` | `20000` | Token reserve used by the automatic threshold. The requested model output allowance wins when it is larger. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
preserves more recent detail but leaves less room for future work. Larger