This commit is contained in:
Dax Raad
2025-08-06 20:20:20 -04:00
parent 390b660c81
commit 323ce486a5
20 changed files with 303 additions and 534 deletions

View File

@@ -5,7 +5,7 @@ import { generateObject, type ModelMessage } from "ai"
import PROMPT_GENERATE from "./generate.txt"
import { SystemPrompt } from "../session/system"
import { State } from "../project/state"
import { Session } from "../session"
import { Paths } from "../project/path"
export namespace Agent {
export const Info = z
@@ -26,7 +26,7 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>
const state = State.create(
() => Session.use().directory,
() => Paths.use().directory,
async () => {
const cfg = await Config.get()
const result: Record<string, Info> = {

View File

@@ -7,7 +7,7 @@ import path from "path"
import os from "os"
import { z } from "zod"
import { Project } from "../project/project"
import { Worktree } from "../project/worktree"
import { Paths } from "../project/path"
export namespace App {
const log = Log.create({ service: "app" })
@@ -100,41 +100,28 @@ export namespace App {
return ctx.provide(app, async () => {
return Project.provide(project, async () => {
return Worktree.provide(project.worktree, async () => {
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
return Paths.provide(
{
worktree: app.info.path.root,
directory: app.info.path.cwd,
},
async () => {
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
}
}
})
},
)
})
})
}
export function state<State>(
key: any,
init: (app: Info) => State,
shutdown?: (state: Awaited<State>) => Promise<void>,
) {
return () => {
const app = ctx.use()
const services = app.services
if (!services.has(key)) {
log.info("registering service", { name: key })
services.set(key, {
state: init(app.info),
shutdown,
})
}
return services.get(key)?.state as State
}
}
export function info() {
return ctx.use().info
}

View File

@@ -1,18 +1,11 @@
import { z, type ZodType } from "zod"
import { App } from "../app/app"
import { Log } from "../util/log"
export namespace Bus {
const log = Log.create({ service: "bus" })
type Subscription = (event: any) => void
const state = App.state("bus", () => {
const subscriptions = new Map<any, Subscription[]>()
return {
subscriptions,
}
})
const subscriptions = new Map<any, Subscription[]>()
export type EventDefinition = ReturnType<typeof event>
@@ -63,7 +56,7 @@ export namespace Bus {
})
const pending = []
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key)
const match = subscriptions.get(key)
for (const sub of match ?? []) {
pending.push(sub(payload))
}
@@ -96,7 +89,6 @@ export namespace Bus {
function raw(type: string, callback: (event: any) => void) {
log.info("subscribing", { type })
const subscriptions = state().subscriptions
let match = subscriptions.get(type) ?? []
match.push(callback)
subscriptions.set(type, match)

View File

@@ -1,5 +1,4 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Plugin } from "../plugin"
@@ -11,7 +10,6 @@ export async function bootstrap<T>(input: App.Input, cb: (app: App.Info) => Prom
Share.init()
Format.init()
Plugin.init()
ConfigHooks.init()
LSP.init()
Snapshot.init()

View File

@@ -14,18 +14,19 @@ import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { State } from "../project/state"
import { Project } from "../project/project"
import { Paths } from "../project/path"
export namespace Config {
const log = Log.create({ service: "config" })
export const state = State.create(
() => Project.use().worktree,
() => Paths.use().directory,
async () => {
const paths = Paths.use()
const auth = await Auth.all()
let result = await global()
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, app.path.cwd, app.path.root)
const found = await Filesystem.findUp(file, paths.directory, paths.worktree)
for (const resolved of found.toReversed()) {
result = mergeDeep(result, await loadFile(resolved))
}
@@ -48,7 +49,7 @@ export namespace Config {
result.agent = result.agent || {}
const markdownAgents = [
...(await Filesystem.globUp("agent/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/agent/*.md", app.path.cwd, app.path.root)),
...(await Filesystem.globUp(".opencode/agent/*.md", paths.directory, paths.worktree)),
]
for (const item of markdownAgents) {
const content = await Bun.file(item).text()
@@ -74,7 +75,7 @@ export namespace Config {
result.mode = result.mode || {}
const markdownModes = [
...(await Filesystem.globUp("mode/*.md", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/mode/*.md", app.path.cwd, app.path.root)),
...(await Filesystem.globUp(".opencode/mode/*.md", paths.directory, paths.worktree)),
]
for (const item of markdownModes) {
const content = await Bun.file(item).text()
@@ -100,7 +101,7 @@ export namespace Config {
result.plugin.push(
...[
...(await Filesystem.globUp("plugin/*.ts", Global.Path.config, Global.Path.config)),
...(await Filesystem.globUp(".opencode/plugin/*.ts", app.path.cwd, app.path.root)),
...(await Filesystem.globUp(".opencode/plugin/*.ts", paths.directory, paths.worktree)),
].map((x) => "file://" + x),
)
@@ -328,32 +329,7 @@ export namespace Config {
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
})
.optional(),
experimental: z
.object({
hook: z
.object({
file_edited: z
.record(
z.string(),
z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array(),
)
.optional(),
session_completed: z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array()
.optional(),
})
.optional(),
})
.optional(),
experimental: z.object({}).optional(),
})
.strict()
.openapi({

View File

@@ -1,55 +0,0 @@
import { App } from "../app/app"
import { Bus } from "../bus"
import { File } from "../file"
import { Session } from "../session"
import { Log } from "../util/log"
import { Config } from "./config"
import path from "path"
export namespace ConfigHooks {
const log = Log.create({ service: "config.hooks" })
export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
const cfg = await Config.get()
const ext = path.extname(payload.properties.file)
for (const item of cfg.experimental?.hook?.file_edited?.[ext] ?? []) {
log.info("file_edited", {
file: payload.properties.file,
command: item.command,
})
Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", payload.properties.file)),
env: item.environment,
cwd: app.path.cwd,
stdout: "ignore",
stderr: "ignore",
})
}
})
Bus.subscribe(Session.Event.Idle, async (payload) => {
const cfg = await Config.get()
if (cfg.experimental?.hook?.session_completed) {
const session = await Session.get(payload.properties.sessionID)
// Only fire hook for top-level sessions (not subagent sessions)
if (session.parentID) return
for (const item of cfg.experimental.hook.session_completed) {
log.info("session_completed", {
command: item.command,
})
Bun.spawn({
cmd: item.command,
cwd: App.info().path.cwd,
env: item.environment,
stdout: "ignore",
stderr: "ignore",
})
}
}
})
}
}

View File

@@ -4,6 +4,7 @@ import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Paths } from "../project/path"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
@@ -18,7 +19,7 @@ export namespace FileWatcher {
),
}
const state = App.state(
"file.watcher",
() => Paths.use().worktree,
() => {
const app = App.use()
if (!app.info.git) return {}

View File

@@ -7,35 +7,40 @@ import path from "path"
import * as Formatter from "./formatter"
import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { State } from "../project/state"
import { Paths } from "../project/path"
export namespace Format {
const log = Log.create({ service: "format" })
const state = App.state("format", async () => {
const enabled: Record<string, boolean> = {}
const cfg = await Config.get()
const state = State.create(
() => Paths.use().directory,
async () => {
const enabled: Record<string, boolean> = {}
const cfg = await Config.get()
const formatters = { ...Formatter } as Record<string, Formatter.Info>
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
const formatters = { ...Formatter } as Record<string, Formatter.Info>
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
result.enabled = async () => true
result.name = name
formatters[name] = result
}
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
result.enabled = async () => true
result.name = name
formatters[name] = result
}
return {
enabled,
formatters,
}
})
return {
enabled,
formatters,
}
},
)
async function isEnabled(item: Formatter.Info) {
const s = await state()

View File

@@ -7,7 +7,7 @@ import { z } from "zod"
import { Config } from "../config/config"
import { spawn } from "child_process"
import { State } from "../project/state"
import { Worktree } from "../project/worktree"
import { Paths } from "../project/path"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -56,7 +56,7 @@ export namespace LSP {
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
const state = State.create(
() => Worktree.use(),
() => Paths.use().worktree,
async () => {
const clients: LSPClient.Info[] = []
const servers: Record<string, LSPServer.Info> = LSPServer

View File

@@ -9,6 +9,7 @@ import { NamedError } from "../util/error"
import { z } from "zod"
import { Session } from "../session"
import { Bus } from "../bus"
import { State } from "../project/state"
export namespace MCP {
const log = Log.create({ service: "mcp" })
@@ -20,7 +21,7 @@ export namespace MCP {
}),
)
const state = App.state(
const state = State.create(
"mcp",
async () => {
const cfg = await Config.get()

View File

@@ -1,9 +1,10 @@
import { App } from "../app/app"
import { z } from "zod"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { Identifier } from "../id/id"
import { Plugin } from "../plugin"
import { State } from "../project/state"
import { Project } from "../project/project"
export namespace Permission {
const log = Log.create({ service: "permission" })
@@ -35,8 +36,8 @@ export namespace Permission {
),
}
const state = App.state(
"permission",
const state = State.create(
() => Project.use().id,
() => {
const pending: {
[sessionID: string]: {

View File

@@ -1,43 +1,47 @@
import type { Hooks, Plugin as PluginInstance } from "@opencode-ai/plugin"
import { App } from "../app/app"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../server/server"
import { BunProc } from "../bun"
import { State } from "../project/state"
import { Paths } from "../project/path"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
const state = App.state("plugin", async (app) => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
fetch: async (...args) => Server.app().fetch(...args),
})
const config = await Config.get()
const hooks = []
for (let plugin of config.plugin ?? []) {
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const [pkg, version] = plugin.split("@")
plugin = await BunProc.install(pkg, version ?? "latest")
const state = State.create(
() => Paths.directory,
async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
fetch: async (...args) => Server.app().fetch(...args),
})
const config = await Config.get()
const hooks = []
for (let plugin of config.plugin ?? []) {
log.info("loading plugin", { path: plugin })
if (!plugin.startsWith("file://")) {
const [pkg, version] = plugin.split("@")
plugin = await BunProc.install(pkg, version ?? "latest")
}
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
const init = await fn({
client,
app: null as any,
$: Bun.$,
})
hooks.push(init)
}
}
const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
const init = await fn({
client,
app,
$: Bun.$,
})
hooks.push(init)
}
}
return {
hooks,
}
})
return {
hooks,
}
},
)
export async function trigger<
Name extends keyof Required<Hooks>,

View File

@@ -0,0 +1,13 @@
import { Context } from "../util/context"
const context = Context.create<{ directory: string; worktree: string }>("path")
export const Paths = {
provider: context.provide,
get directory() {
return context.use().directory
},
get worktree() {
return context.use().worktree
},
}

View File

@@ -21,7 +21,7 @@ export namespace State {
}
export async function dispose() {
for (const [key, entry] of entries.entries()) {
for (const [_, entry] of entries.entries()) {
if (!entry.dispose) continue
await entry.dispose(await entry.state)
}

View File

@@ -1,7 +0,0 @@
import { Context } from "../util/context"
export namespace Worktree {
const context = Context.create<string>("worktree")
export const use = context.use
export const provide = context.provide
}

View File

@@ -1,5 +1,4 @@
import z from "zod"
import { App } from "../app/app"
import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
@@ -10,6 +9,8 @@ import { AuthCopilot } from "../auth/copilot"
import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { State } from "../project/state"
import { Paths } from "../project/path"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -216,149 +217,152 @@ export namespace Provider {
},
}
const state = App.state("provider", async () => {
const config = await Config.get()
const database = await ModelsDev.get()
const state = State.create(
() => Paths.directory,
async () => {
const config = await Config.get()
const database = await ModelsDev.get()
const providers: {
[providerID: string]: {
source: Source
info: ModelsDev.Provider
getModel?: (sdk: any, modelID: string) => Promise<any>
options: Record<string, any>
}
} = {}
const models = new Map<string, { info: ModelsDev.Model; language: LanguageModel }>()
const sdk = new Map<string, SDK>()
log.info("init")
function mergeProvider(
id: string,
options: Record<string, any>,
source: Source,
getModel?: (sdk: any, modelID: string) => Promise<any>,
) {
const provider = providers[id]
if (!provider) {
const info = database[id]
if (!info) return
if (info.api && !options["baseURL"]) options["baseURL"] = info.api
providers[id] = {
source,
info,
options,
getModel,
const providers: {
[providerID: string]: {
source: Source
info: ModelsDev.Provider
getModel?: (sdk: any, modelID: string) => Promise<any>
options: Record<string, any>
}
return
}
provider.options = mergeDeep(provider.options, options)
provider.source = source
provider.getModel = getModel ?? provider.getModel
}
} = {}
const models = new Map<string, { info: ModelsDev.Model; language: LanguageModel }>()
const sdk = new Map<string, SDK>()
const configProviders = Object.entries(config.provider ?? {})
log.info("init")
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
id: providerID,
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
models: existing?.models ?? {},
function mergeProvider(
id: string,
options: Record<string, any>,
source: Source,
getModel?: (sdk: any, modelID: string) => Promise<any>,
) {
const provider = providers[id]
if (!provider) {
const info = database[id]
if (!info) return
if (info.api && !options["baseURL"]) options["baseURL"] = info.api
providers[id] = {
source,
info,
options,
getModel,
}
return
}
provider.options = mergeDeep(provider.options, options)
provider.source = source
provider.getModel = getModel ?? provider.getModel
}
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const existing = parsed.models[modelID]
const parsedModel: ModelsDev.Model = {
id: modelID,
name: model.name ?? existing?.name ?? modelID,
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
tool_call: model.tool_call ?? existing?.tool_call ?? true,
cost:
!model.cost && !existing?.cost
? {
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
}
: {
cache_read: 0,
cache_write: 0,
...existing?.cost,
...model.cost,
},
options: {
...existing?.options,
...model.options,
},
limit: model.limit ??
existing?.limit ?? {
context: 0,
output: 0,
const configProviders = Object.entries(config.provider ?? {})
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
id: providerID,
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
models: existing?.models ?? {},
}
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const existing = parsed.models[modelID]
const parsedModel: ModelsDev.Model = {
id: modelID,
name: model.name ?? existing?.name ?? modelID,
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
tool_call: model.tool_call ?? existing?.tool_call ?? true,
cost:
!model.cost && !existing?.cost
? {
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
}
: {
cache_read: 0,
cache_write: 0,
...existing?.cost,
...model.cost,
},
options: {
...existing?.options,
...model.options,
},
limit: model.limit ??
existing?.limit ?? {
context: 0,
output: 0,
},
}
parsed.models[modelID] = parsedModel
}
parsed.models[modelID] = parsedModel
database[providerID] = parsed
}
database[providerID] = parsed
}
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => process.env[item]).at(0)
if (!apiKey) continue
mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
}
// load apikeys
for (const [providerID, provider] of Object.entries(await Auth.all())) {
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api")
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => process.env[item]).at(0)
if (!apiKey) continue
mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
}
}
// load custom
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result && (result.autoload || providers[providerID])) {
mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
// load apikeys
for (const [providerID, provider] of Object.entries(await Auth.all())) {
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api")
}
}
}
// load config
for (const [providerID, provider] of configProviders) {
mergeProvider(providerID, provider.options ?? {}, "config")
}
for (const [providerID, provider] of Object.entries(providers)) {
if (Object.keys(provider.info.models).length === 0) {
delete providers[providerID]
continue
// load custom
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result && (result.autoload || providers[providerID])) {
mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
}
}
log.info("found", { providerID })
}
return {
models,
providers,
sdk,
}
})
// load config
for (const [providerID, provider] of configProviders) {
mergeProvider(providerID, provider.options ?? {}, "config")
}
for (const [providerID, provider] of Object.entries(providers)) {
if (Object.keys(provider.info.models).length === 0) {
delete providers[providerID]
continue
}
log.info("found", { providerID })
}
return {
models,
providers,
sdk,
}
},
)
export async function list() {
return state().then((state) => state.providers)

View File

@@ -42,16 +42,12 @@ import { mergeDeep, pipe, splitWhen } from "remeda"
import { ToolRegistry } from "../tool/registry"
import { Plugin } from "../plugin"
import { Project } from "../project/project"
import { Context } from "../util/context"
import { State } from "../project/state"
import { Paths } from "../project/path"
export namespace Session {
const log = Log.create({ service: "session" })
const context = Context.create<Info>("session")
export const use = context.use
export const provide = context.provide
const OUTPUT_TOKEN_MAX = 32_000
const parentSessionTitlePrefix = "New session - "
@@ -135,8 +131,8 @@ export namespace Session {
),
}
const state = App.state(
"session",
const state = State.create(
() => Paths.directory,
() => {
const pending = new Map<string, AbortController>()
const autoCompacting = new Map<string, boolean>()

View File

@@ -1,7 +1,8 @@
import { App } from "../app/app"
import { Config } from "../config/config"
import z from "zod"
import { Provider } from "../provider/provider"
import { State } from "../project/state"
import { Paths } from "../project/path"
export namespace Mode {
export const Info = z
@@ -22,47 +23,50 @@ export namespace Mode {
ref: "Mode",
})
export type Info = z.infer<typeof Info>
const state = App.state("mode", async () => {
const cfg = await Config.get()
const model = cfg.model ? Provider.parseModel(cfg.model) : undefined
const result: Record<string, Info> = {
build: {
model,
name: "build",
tools: {},
},
plan: {
name: "plan",
model,
tools: {
write: false,
edit: false,
patch: false,
},
},
}
for (const [key, value] of Object.entries(cfg.mode ?? {})) {
if (value.disable) continue
let item = result[key]
if (!item)
item = result[key] = {
name: key,
const state = State.create(
() => Paths.directory,
async () => {
const cfg = await Config.get()
const model = cfg.model ? Provider.parseModel(cfg.model) : undefined
const result: Record<string, Info> = {
build: {
model,
name: "build",
tools: {},
}
item.name = key
if (value.model) item.model = Provider.parseModel(value.model)
if (value.prompt) item.prompt = value.prompt
if (value.temperature != undefined) item.temperature = value.temperature
if (value.top_p != undefined) item.topP = value.top_p
if (value.tools)
item.tools = {
...value.tools,
...item.tools,
}
}
},
plan: {
name: "plan",
model,
tools: {
write: false,
edit: false,
patch: false,
},
},
}
for (const [key, value] of Object.entries(cfg.mode ?? {})) {
if (value.disable) continue
let item = result[key]
if (!item)
item = result[key] = {
name: key,
tools: {},
}
item.name = key
if (value.model) item.model = Provider.parseModel(value.model)
if (value.prompt) item.prompt = value.prompt
if (value.temperature != undefined) item.temperature = value.temperature
if (value.top_p != undefined) item.topP = value.top_p
if (value.tools)
item.tools = {
...value.tools,
...item.tools,
}
}
return result
})
return result
},
)
export async function get(mode: string) {
return state().then((x) => x[mode])

View File

@@ -1,155 +0,0 @@
import { Log } from "../util/log"
import { App } from "../app/app"
import { Bus } from "../bus"
import path from "path"
import z from "zod"
import fs from "fs/promises"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
export namespace Storage {
const log = Log.create({ service: "storage" })
export const Event = {
Write: Bus.event("storage.write", z.object({ key: z.string(), content: z.any() })),
}
type Migration = (dir: string) => Promise<void>
const MIGRATIONS: Migration[] = [
async (dir: string) => {
try {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
const content = await Bun.file(file).json()
if (!content.metadata) continue
log.info("migrating to v2 message", { file })
try {
const result = MessageV2.fromV1(content)
await Bun.write(
file,
JSON.stringify(
{
...result.info,
parts: result.parts,
},
null,
2,
),
)
} catch (e) {
await fs.rename(file, file.replace("storage", "broken"))
}
}
} catch {}
},
async (dir: string) => {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
try {
const { parts, ...info } = await Bun.file(file).json()
if (!parts) continue
for (const part of parts) {
const id = Identifier.ascending("part")
await Bun.write(
[dir, "session", "part", info.sessionID, info.id, id + ".json"].join("/"),
JSON.stringify({
...part,
id,
sessionID: info.sessionID,
messageID: info.id,
...(part.type === "tool" ? { callID: part.id } : {}),
}),
)
}
await Bun.write(file, JSON.stringify(info, null, 2))
} catch (e) {}
}
},
async (dir: string) => {
const files = new Bun.Glob("session/message/*/*.json").scanSync({
cwd: dir,
absolute: true,
})
for (const file of files) {
try {
const content = await Bun.file(file).json()
if (content.role === "assistant" && !content.mode) {
log.info("adding mode field to message", { file })
content.mode = "build"
await Bun.write(file, JSON.stringify(content, null, 2))
}
} catch (e) {}
}
},
]
const state = App.state("storage", async () => {
const app = App.info()
const dir = path.normalize(path.join(app.path.data, "storage"))
await fs.mkdir(dir, { recursive: true })
const migration = await Bun.file(path.join(dir, "migration"))
.json()
.then((x) => parseInt(x))
.catch(() => 0)
for (let index = migration; index < MIGRATIONS.length; index++) {
log.info("running migration", { index })
const migration = MIGRATIONS[index]
await migration(dir)
await Bun.write(path.join(dir, "migration"), (index + 1).toString())
}
return {
dir,
}
})
export async function remove(key: string) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key + ".json")
await fs.unlink(target).catch(() => {})
}
export async function removeDir(key: string) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key)
await fs.rm(target, { recursive: true, force: true }).catch(() => {})
}
export async function readJSON<T>(key: string) {
const dir = await state().then((x) => x.dir)
return Bun.file(path.join(dir, key + ".json")).json() as Promise<T>
}
export async function writeJSON<T>(key: string, content: T) {
const dir = await state().then((x) => x.dir)
const target = path.join(dir, key + ".json")
const tmp = target + Date.now() + ".tmp"
await Bun.write(tmp, JSON.stringify(content, null, 2))
await fs.rename(tmp, target).catch(() => {})
await fs.unlink(tmp).catch(() => {})
Bus.publish(Event.Write, { key, content })
}
const glob = new Bun.Glob("**/*")
export async function list(prefix: string) {
const dir = await state().then((x) => x.dir)
try {
const result = await Array.fromAsync(
glob.scan({
cwd: path.join(dir, prefix),
onlyFiles: true,
}),
).then((items) => items.map((item) => path.join(prefix, item.slice(0, -5))))
result.sort()
return result
} catch {
return []
}
}
}

View File

@@ -1,7 +1,8 @@
import { z } from "zod"
import { Tool } from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt"
import { App } from "../app/app"
import { State } from "../project/state"
import { Project } from "../project/project"
const TodoInfo = z.object({
content: z.string().describe("Brief description of the task"),
@@ -11,12 +12,15 @@ const TodoInfo = z.object({
})
type TodoInfo = z.infer<typeof TodoInfo>
const state = App.state("todo-tool", () => {
const todos: {
[sessionId: string]: TodoInfo[]
} = {}
return todos
})
const state = State.create(
() => Project.use().id,
() => {
const todos: {
[sessionId: string]: TodoInfo[]
} = {}
return todos
},
)
export const TodoWriteTool = Tool.define("todowrite", {
description: DESCRIPTION_WRITE,