Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ export namespace Plugin {
return {
hooks,
input,
unsubscribe: undefined as (() => void) | undefined,
}
},
async (state) => {
state.unsubscribe?.()
for (const hook of state.hooks) {
await hook.dispose?.()
}
})

Expand All @@ -117,13 +124,13 @@ export namespace Plugin {
}

export async function init() {
const hooks = await state().then((x) => x.hooks)
const s = await state()
const config = await Config.get()
for (const hook of hooks) {
for (const hook of s.hooks) {
// @ts-expect-error this is because we haven't moved plugin to sdk v2
await hook.config?.(config)
}
Bus.subscribeAll(async (input) => {
s.unsubscribe = Bus.subscribeAll(async (input) => {
const hooks = await state().then((x) => x.hooks)
for (const hook of hooks) {
hook["event"]?.({
Expand Down
21 changes: 15 additions & 6 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"

const commandSubscription = Instance.state(
() => {
const unsubscribe = Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
await Project.setInitialized(Instance.project.id)
}
})
return { unsubscribe }
},
async (state) => {
state.unsubscribe()
},
)

export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init()
Expand All @@ -22,10 +36,5 @@ export async function InstanceBootstrap() {
FileWatcher.init()
File.init()
Vcs.init()

Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
await Project.setInitialized(Instance.project.id)
}
})
commandSubscription()
}
13 changes: 12 additions & 1 deletion packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@ import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
import { createLruCache } from "@/util/cache"

interface Context {
directory: string
worktree: string
project: Project.Info
}
const context = Context.create<Context>("instance")
const cache = new Map<string, Promise<Context>>()
const cache = createLruCache<string, Promise<Context>>({
maxEntries: 20,
onEvict: async (_key, value) => {
const ctx = await value.catch(() => null)
if (ctx) {
await context.provide(ctx, async () => {
await State.dispose(ctx.directory)
})
}
},
})

export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
Expand Down
15 changes: 13 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Env } from "../env"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { createLruCache } from "@/util/cache"

// Direct imports for bundled providers
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
Expand Down Expand Up @@ -680,7 +681,9 @@ export namespace Provider {
}

const providers: { [providerID: string]: Info } = {}
const languages = new Map<string, LanguageModelV2>()
const languages = createLruCache<string, LanguageModelV2>({
maxEntries: 100,
})
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
Expand Down Expand Up @@ -948,7 +951,15 @@ export namespace Provider {
return {
models: languages,
providers,
sdk,
sdk: createLruCache({
maxEntries: 50,
onEvict: (key, sdk) => {
// SDK may have cleanup methods
if (sdk && typeof sdk === "object" && "destroy" in sdk) {
sdk.destroy?.()
}
},
}),
modelLoaders,
}
})
Expand Down
86 changes: 86 additions & 0 deletions packages/opencode/src/util/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* LRU cache with max entries limit for preventing memory leaks
*/

export type LruCacheOpts = {
maxEntries?: number
onEvict?: (key: any, value: any) => void
}

type LruCacheEntry<V> = {
value: V
lastAccess: number
}

export function createLruCache<K = any, V = any>(opts: LruCacheOpts = {}) {
const { maxEntries = Infinity, onEvict } = opts
const cache = new Map<K, LruCacheEntry<V>>()

function evictOne() {
let oldestKey: K | null = null
let oldestAccess = Infinity

for (const [key, entry] of cache) {
if (entry.lastAccess < oldestAccess) {
oldestAccess = entry.lastAccess
oldestKey = key
}
}

if (oldestKey !== null) {
delete_(oldestKey)
}
}

function delete_(key: K): boolean {
const entry = cache.get(key)
if (!entry) return false
onEvict?.(key, entry.value)
return cache.delete(key)
}

return {
get(key: K): V | undefined {
const entry = cache.get(key)
if (!entry) return undefined
entry.lastAccess = Date.now()
return entry.value
},

set(key: K, value: V): void {
if (cache.size >= maxEntries && !cache.has(key)) {
evictOne()
}
cache.set(key, { value, lastAccess: Date.now() })
},

has(key: K): boolean {
return cache.has(key)
},

delete(key: K): boolean {
return delete_(key)
},

clear(): void {
for (const [key, entry] of cache) {
onEvict?.(key, entry.value)
}
cache.clear()
},

get size() {
return cache.size
},

*[Symbol.iterator](): IterableIterator<[K, V]> {
for (const [key, entry] of cache) {
yield [key, entry.value]
}
},

entries(): IterableIterator<[K, V]> {
return this[Symbol.iterator]()
},
}
}
4 changes: 4 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,8 @@ export interface Hooks {
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>
/**
* Called when the plugin is being disposed/cleaned up
*/
dispose?: () => Promise<void>
}