mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 03:45:23 +00:00
progress
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { App } from "../app/app"
|
||||
import { Config } from "../config/config"
|
||||
import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
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"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
@@ -24,46 +25,49 @@ export namespace Agent {
|
||||
ref: "Agent",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
const state = App.state("agent", async () => {
|
||||
const cfg = await Config.get()
|
||||
const result: Record<string, Info> = {
|
||||
general: {
|
||||
name: "general",
|
||||
description:
|
||||
"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
|
||||
tools: {
|
||||
todoread: false,
|
||||
todowrite: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
if (value.disable) {
|
||||
delete result[key]
|
||||
continue
|
||||
}
|
||||
let item = result[key]
|
||||
if (!item)
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
description: "",
|
||||
const state = State.create(
|
||||
() => Session.use().directory,
|
||||
async () => {
|
||||
const cfg = await Config.get()
|
||||
const result: Record<string, Info> = {
|
||||
general: {
|
||||
name: "general",
|
||||
description:
|
||||
"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
|
||||
tools: {
|
||||
todowrite: false,
|
||||
todoread: false,
|
||||
todowrite: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
if (value.disable) {
|
||||
delete result[key]
|
||||
continue
|
||||
}
|
||||
const model = value.model ?? cfg.model
|
||||
if (model) item.model = Provider.parseModel(model)
|
||||
if (value.prompt) item.prompt = value.prompt
|
||||
if (value.tools)
|
||||
item.tools = {
|
||||
...item.tools,
|
||||
...value.tools,
|
||||
}
|
||||
if (value.description) item.description = value.description
|
||||
}
|
||||
return result
|
||||
})
|
||||
let item = result[key]
|
||||
if (!item)
|
||||
item = result[key] = {
|
||||
name: key,
|
||||
description: "",
|
||||
tools: {
|
||||
todowrite: false,
|
||||
todoread: false,
|
||||
},
|
||||
}
|
||||
const model = value.model ?? cfg.model
|
||||
if (model) item.model = Provider.parseModel(model)
|
||||
if (value.prompt) item.prompt = value.prompt
|
||||
if (value.tools)
|
||||
item.tools = {
|
||||
...item.tools,
|
||||
...value.tools,
|
||||
}
|
||||
if (value.description) item.description = value.description
|
||||
}
|
||||
return result
|
||||
},
|
||||
)
|
||||
|
||||
export async function get(agent: string) {
|
||||
return state().then((x) => x[agent])
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Global } from "../global"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { z } from "zod"
|
||||
import { Project } from "../project/project"
|
||||
import { Worktree } from "../project/worktree"
|
||||
|
||||
export namespace App {
|
||||
const log = Log.create({ service: "app" })
|
||||
@@ -87,17 +89,30 @@ export namespace App {
|
||||
info,
|
||||
}
|
||||
|
||||
const projects = await Project.list()
|
||||
const project = projects
|
||||
.toSorted((a, b) => a.worktree.length - b.worktree.length)
|
||||
.find((x) => Filesystem.contains(x.worktree, input.cwd))
|
||||
|
||||
if (!project) {
|
||||
throw new Error("No project found")
|
||||
}
|
||||
|
||||
return ctx.provide(app, 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 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ export namespace Bus {
|
||||
.object({
|
||||
type: z.literal(type),
|
||||
properties: def.properties,
|
||||
context: z.object({
|
||||
projectID: z.string().optional(),
|
||||
sessionID: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
ref: "Event" + "." + def.type,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Project } from "../../../project/project"
|
||||
import { Log } from "../../../util/log"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
command: "scrap",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {},
|
||||
async handler() {
|
||||
const timer = Log.Default.time("scrap")
|
||||
const list = await Project.list()
|
||||
console.log(list)
|
||||
timer.stop()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { z } from "zod"
|
||||
import { App } from "../app/app"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { mergeDeep, pipe } from "remeda"
|
||||
@@ -14,110 +13,115 @@ import matter from "gray-matter"
|
||||
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"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
export const state = App.state("config", async (app) => {
|
||||
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)
|
||||
for (const resolved of found.toReversed()) {
|
||||
result = mergeDeep(result, await loadFile(resolved))
|
||||
export const state = State.create(
|
||||
() => Project.use().worktree,
|
||||
async () => {
|
||||
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)
|
||||
for (const resolved of found.toReversed()) {
|
||||
result = mergeDeep(result, await loadFile(resolved))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override with custom config if provided
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
process.env[value.key] = value.token
|
||||
const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
|
||||
result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
|
||||
// Override with custom config if provided
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_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)),
|
||||
]
|
||||
for (const item of markdownAgents) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
||||
const config = {
|
||||
name: path.basename(item, ".md"),
|
||||
...md.data,
|
||||
prompt: md.content.trim(),
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
process.env[value.key] = value.token
|
||||
const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
|
||||
result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
|
||||
}
|
||||
}
|
||||
const parsed = Agent.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result.agent = mergeDeep(result.agent, {
|
||||
[config.name]: parsed.data,
|
||||
})
|
||||
continue
|
||||
|
||||
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)),
|
||||
]
|
||||
for (const item of markdownAgents) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
||||
const config = {
|
||||
name: path.basename(item, ".md"),
|
||||
...md.data,
|
||||
prompt: md.content.trim(),
|
||||
}
|
||||
const parsed = Agent.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result.agent = mergeDeep(result.agent, {
|
||||
[config.name]: parsed.data,
|
||||
})
|
||||
continue
|
||||
}
|
||||
throw new InvalidError({ path: item }, { cause: parsed.error })
|
||||
}
|
||||
throw new InvalidError({ path: item }, { cause: parsed.error })
|
||||
}
|
||||
|
||||
// Load mode markdown files
|
||||
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)),
|
||||
]
|
||||
for (const item of markdownModes) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
// Load mode markdown files
|
||||
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)),
|
||||
]
|
||||
for (const item of markdownModes) {
|
||||
const content = await Bun.file(item).text()
|
||||
const md = matter(content)
|
||||
if (!md.data) continue
|
||||
|
||||
const config = {
|
||||
name: path.basename(item, ".md"),
|
||||
...md.data,
|
||||
prompt: md.content.trim(),
|
||||
const config = {
|
||||
name: path.basename(item, ".md"),
|
||||
...md.data,
|
||||
prompt: md.content.trim(),
|
||||
}
|
||||
const parsed = Mode.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result.mode = mergeDeep(result.mode, {
|
||||
[config.name]: parsed.data,
|
||||
})
|
||||
continue
|
||||
}
|
||||
throw new InvalidError({ path: item }, { cause: parsed.error })
|
||||
}
|
||||
const parsed = Mode.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result.mode = mergeDeep(result.mode, {
|
||||
[config.name]: parsed.data,
|
||||
})
|
||||
continue
|
||||
|
||||
result.plugin = result.plugin || []
|
||||
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)),
|
||||
].map((x) => "file://" + x),
|
||||
)
|
||||
|
||||
// Handle migration from autoshare to share field
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
|
||||
result.keybinds.messages_undo = result.keybinds.messages_revert
|
||||
}
|
||||
throw new InvalidError({ path: item }, { cause: parsed.error })
|
||||
}
|
||||
|
||||
result.plugin = result.plugin || []
|
||||
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)),
|
||||
].map((x) => "file://" + x),
|
||||
)
|
||||
if (!result.username) {
|
||||
const os = await import("os")
|
||||
result.username = os.userInfo().username
|
||||
}
|
||||
|
||||
// Handle migration from autoshare to share field
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
|
||||
result.keybinds.messages_undo = result.keybinds.messages_revert
|
||||
}
|
||||
log.info("loaded", result)
|
||||
|
||||
if (!result.username) {
|
||||
const os = await import("os")
|
||||
result.username = os.userInfo().username
|
||||
}
|
||||
|
||||
log.info("loaded", result)
|
||||
|
||||
return result
|
||||
})
|
||||
return result
|
||||
},
|
||||
)
|
||||
|
||||
export const McpLocal = z
|
||||
.object({
|
||||
|
||||
@@ -11,7 +11,6 @@ export namespace ConfigHooks {
|
||||
|
||||
export function init() {
|
||||
log.info("init")
|
||||
const app = App.info()
|
||||
|
||||
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||
const cfg = await Config.get()
|
||||
|
||||
@@ -6,6 +6,8 @@ import { LSPServer } from "./server"
|
||||
import { z } from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { spawn } from "child_process"
|
||||
import { State } from "../project/state"
|
||||
import { Worktree } from "../project/worktree"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
@@ -53,8 +55,8 @@ export namespace LSP {
|
||||
})
|
||||
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
|
||||
|
||||
const state = App.state(
|
||||
"lsp",
|
||||
const state = State.create(
|
||||
() => Worktree.use(),
|
||||
async () => {
|
||||
const clients: LSPClient.Info[] = []
|
||||
const servers: Record<string, LSPServer.Info> = LSPServer
|
||||
|
||||
67
packages/opencode/src/project/project.ts
Normal file
67
packages/opencode/src/project/project.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import z from "zod"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import { StorageNext } from "../storage/storage-next"
|
||||
import { Context } from "../util/context"
|
||||
|
||||
export namespace Project {
|
||||
export const Info = z.object({
|
||||
id: z.string(),
|
||||
worktree: z.string(),
|
||||
vcs: z.literal("git").optional(),
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
const context = Context.create<Info>("project")
|
||||
|
||||
export const use = context.use
|
||||
export const provide = context.provide
|
||||
|
||||
const init = lazy(async () => {
|
||||
const cwd = process.cwd()
|
||||
const matches = Filesystem.up({ targets: [".git"], start: cwd })
|
||||
const git = await matches.next().then((x) => x.value)
|
||||
await matches.return()
|
||||
if (!git) {
|
||||
await StorageNext.write(["project", "global"], {
|
||||
id: "global",
|
||||
worktree: "/",
|
||||
})
|
||||
return
|
||||
}
|
||||
let worktree = path.dirname(git)
|
||||
const [id] = await $`git rev-list --max-parents=0 --all`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(worktree)
|
||||
.text()
|
||||
.then((x) =>
|
||||
x
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted(),
|
||||
)
|
||||
worktree = path.dirname(
|
||||
await $`git rev-parse --path-format=absolute --git-common-dir`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.cwd(worktree)
|
||||
.text()
|
||||
.then((x) => x.trim()),
|
||||
)
|
||||
await StorageNext.write<Info>(["project", id], {
|
||||
id,
|
||||
worktree,
|
||||
vcs: "git",
|
||||
})
|
||||
})
|
||||
|
||||
export async function list() {
|
||||
await init()
|
||||
const keys = await StorageNext.list(["project"])
|
||||
return await Promise.all(keys.map((x) => StorageNext.read<Info>(x)))
|
||||
}
|
||||
}
|
||||
29
packages/opencode/src/project/state.ts
Normal file
29
packages/opencode/src/project/state.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export namespace State {
|
||||
interface Entry {
|
||||
state: any
|
||||
dispose?: (state: any) => Promise<void>
|
||||
}
|
||||
|
||||
const entries = new Map<string, Entry>()
|
||||
|
||||
export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
|
||||
return () => {
|
||||
const key = root()
|
||||
const exists = entries.get(key)
|
||||
if (exists) return exists.state as S
|
||||
const state = init()
|
||||
entries.set(key, {
|
||||
state,
|
||||
dispose,
|
||||
})
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispose() {
|
||||
for (const [key, entry] of entries.entries()) {
|
||||
if (!entry.dispose) continue
|
||||
await entry.dispose(await entry.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
7
packages/opencode/src/project/worktree.ts
Normal file
7
packages/opencode/src/project/worktree.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Context } from "../util/context"
|
||||
|
||||
export namespace Worktree {
|
||||
const context = Context.create<string>("worktree")
|
||||
export const use = context.use
|
||||
export const provide = context.provide
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "path"
|
||||
import { Decimal } from "decimal.js"
|
||||
import { z, ZodSchema } from "zod"
|
||||
import {
|
||||
@@ -30,7 +29,7 @@ import { ProviderTransform } from "../provider/transform"
|
||||
import type { ModelsDev } from "../provider/models"
|
||||
import { Share } from "../share/share"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Storage } from "../storage/storage"
|
||||
import { StorageNext } from "../storage/storage-next"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "../util/error"
|
||||
import { SystemPrompt } from "./system"
|
||||
@@ -42,10 +41,17 @@ import { ReadTool } from "../tool/read"
|
||||
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"
|
||||
|
||||
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 - "
|
||||
@@ -62,6 +68,9 @@ export namespace Session {
|
||||
export const Info = z
|
||||
.object({
|
||||
id: Identifier.schema("session"),
|
||||
projectID: z.string(),
|
||||
directory: z.string(),
|
||||
worktree: z.string(),
|
||||
parentID: Identifier.schema("session").optional(),
|
||||
share: z
|
||||
.object({
|
||||
@@ -129,8 +138,6 @@ export namespace Session {
|
||||
const state = App.state(
|
||||
"session",
|
||||
() => {
|
||||
const sessions = new Map<string, Info>()
|
||||
const messages = new Map<string, MessageV2.Info[]>()
|
||||
const pending = new Map<string, AbortController>()
|
||||
const autoCompacting = new Map<string, boolean>()
|
||||
const queued = new Map<
|
||||
@@ -145,8 +152,6 @@ export namespace Session {
|
||||
>()
|
||||
|
||||
return {
|
||||
sessions,
|
||||
messages,
|
||||
pending,
|
||||
autoCompacting,
|
||||
queued,
|
||||
@@ -160,19 +165,30 @@ export namespace Session {
|
||||
)
|
||||
|
||||
export async function create(parentID?: string) {
|
||||
const result: Info = {
|
||||
id: Identifier.descending("session"),
|
||||
version: Installation.VERSION,
|
||||
const app = App.info()
|
||||
return createNext({
|
||||
parentID,
|
||||
title: createDefaultTitle(!!parentID),
|
||||
directory: app.path.cwd,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createNext(input: { id?: string; parentID?: string; directory: string }) {
|
||||
const project = Project.use()
|
||||
const result: Info = {
|
||||
id: Identifier.descending("session", input.id),
|
||||
version: Installation.VERSION,
|
||||
projectID: project.id,
|
||||
worktree: project.worktree,
|
||||
directory: input.directory,
|
||||
parentID: input.parentID,
|
||||
title: createDefaultTitle(!!input.parentID),
|
||||
time: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
}
|
||||
log.info("created", result)
|
||||
state().sessions.set(result.id, result)
|
||||
await Storage.writeJSON("session/info/" + result.id, result)
|
||||
await StorageNext.write(["session", project.id, result.id], result)
|
||||
const cfg = await Config.get()
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
|
||||
share(result.id)
|
||||
@@ -191,17 +207,13 @@ export namespace Session {
|
||||
}
|
||||
|
||||
export async function get(id: string) {
|
||||
const result = state().sessions.get(id)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
const read = await Storage.readJSON<Info>("session/info/" + id)
|
||||
state().sessions.set(id, read)
|
||||
const project = Project.use()
|
||||
const read = await StorageNext.read<Info>(["session", project.id, id])
|
||||
return read as Info
|
||||
}
|
||||
|
||||
export async function getShare(id: string) {
|
||||
return Storage.readJSON<ShareInfo>("session/share/" + id)
|
||||
return StorageNext.read<ShareInfo>(["share", id])
|
||||
}
|
||||
|
||||
export async function share(id: string) {
|
||||
@@ -218,7 +230,7 @@ export namespace Session {
|
||||
url: share.url,
|
||||
}
|
||||
})
|
||||
await Storage.writeJSON<ShareInfo>("session/share/" + id, share)
|
||||
await StorageNext.write(["share", id], share)
|
||||
await Share.sync("session/info/" + id, session)
|
||||
for (const msg of await messages(id)) {
|
||||
await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info)
|
||||
@@ -232,7 +244,7 @@ export namespace Session {
|
||||
export async function unshare(id: string) {
|
||||
const share = await getShare(id)
|
||||
if (!share) return
|
||||
await Storage.remove("session/share/" + id)
|
||||
await StorageNext.remove(["share", id])
|
||||
await update(id, (draft) => {
|
||||
draft.share = undefined
|
||||
})
|
||||
@@ -240,17 +252,15 @@ export namespace Session {
|
||||
}
|
||||
|
||||
export async function update(id: string, editor: (session: Info) => void) {
|
||||
const { sessions } = state()
|
||||
const session = await get(id)
|
||||
if (!session) return
|
||||
editor(session)
|
||||
session.time.updated = Date.now()
|
||||
sessions.set(id, session)
|
||||
await Storage.writeJSON("session/info/" + id, session)
|
||||
Bus.publish(Event.Updated, {
|
||||
info: session,
|
||||
const project = Project.use()
|
||||
const result = await StorageNext.update<Info>(["session", project.id, id], (draft) => {
|
||||
editor(draft)
|
||||
draft.time.updated = Date.now()
|
||||
})
|
||||
return session
|
||||
Bus.publish(Event.Updated, {
|
||||
info: result,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
export async function messages(sessionID: string) {
|
||||
@@ -258,11 +268,11 @@ export namespace Session {
|
||||
info: MessageV2.Info
|
||||
parts: MessageV2.Part[]
|
||||
}[]
|
||||
for (const p of await Storage.list("session/message/" + sessionID)) {
|
||||
const read = await Storage.readJSON<MessageV2.Info>(p)
|
||||
for (const p of await StorageNext.list(["message", sessionID])) {
|
||||
const read = await StorageNext.read<MessageV2.Info>(p)
|
||||
result.push({
|
||||
info: read,
|
||||
parts: await getParts(sessionID, read.id),
|
||||
parts: await getParts(read.id),
|
||||
})
|
||||
}
|
||||
result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1))
|
||||
@@ -271,15 +281,15 @@ export namespace Session {
|
||||
|
||||
export async function getMessage(sessionID: string, messageID: string) {
|
||||
return {
|
||||
info: await Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID),
|
||||
parts: await getParts(sessionID, messageID),
|
||||
info: await StorageNext.read<MessageV2.Info>(["message", sessionID, messageID]),
|
||||
parts: await getParts(messageID),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getParts(sessionID: string, messageID: string) {
|
||||
export async function getParts(messageID: string) {
|
||||
const result = [] as MessageV2.Part[]
|
||||
for (const item of await Storage.list("session/part/" + sessionID + "/" + messageID)) {
|
||||
const read = await Storage.readJSON<MessageV2.Part>(item)
|
||||
for (const item of await StorageNext.list(["part", messageID])) {
|
||||
const read = await StorageNext.read<MessageV2.Part>(item)
|
||||
result.push(read)
|
||||
}
|
||||
result.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||
@@ -287,17 +297,17 @@ export namespace Session {
|
||||
}
|
||||
|
||||
export async function* list() {
|
||||
for (const item of await Storage.list("session/info")) {
|
||||
const sessionID = path.basename(item, ".json")
|
||||
yield get(sessionID)
|
||||
const project = Project.use()
|
||||
for (const item of await StorageNext.list(["session", project.id])) {
|
||||
yield StorageNext.read<Info>(item)
|
||||
}
|
||||
}
|
||||
|
||||
export async function children(parentID: string) {
|
||||
const project = Project.use()
|
||||
const result = [] as Session.Info[]
|
||||
for (const item of await Storage.list("session/info")) {
|
||||
const sessionID = path.basename(item, ".json")
|
||||
const session = await get(sessionID)
|
||||
for (const item of await StorageNext.list(["session", project.id])) {
|
||||
const session = await StorageNext.read<Info>(item)
|
||||
if (session.parentID !== parentID) continue
|
||||
result.push(session)
|
||||
}
|
||||
@@ -316,6 +326,7 @@ export namespace Session {
|
||||
}
|
||||
|
||||
export async function remove(sessionID: string, emitEvent = true) {
|
||||
const project = Project.use()
|
||||
try {
|
||||
abort(sessionID)
|
||||
const session = await get(sessionID)
|
||||
@@ -323,10 +334,13 @@ export namespace Session {
|
||||
await remove(child.id, false)
|
||||
}
|
||||
await unshare(sessionID).catch(() => {})
|
||||
await Storage.remove(`session/info/${sessionID}`).catch(() => {})
|
||||
await Storage.removeDir(`session/message/${sessionID}/`).catch(() => {})
|
||||
state().sessions.delete(sessionID)
|
||||
state().messages.delete(sessionID)
|
||||
for (const msg of await StorageNext.list(["message", sessionID])) {
|
||||
for (const part of await StorageNext.list(["part", msg.at(-1)!])) {
|
||||
await StorageNext.remove(part)
|
||||
}
|
||||
await StorageNext.remove(msg)
|
||||
}
|
||||
await StorageNext.remove(["session", project.id, sessionID])
|
||||
if (emitEvent) {
|
||||
Bus.publish(Event.Deleted, {
|
||||
info: session,
|
||||
@@ -338,14 +352,14 @@ export namespace Session {
|
||||
}
|
||||
|
||||
async function updateMessage(msg: MessageV2.Info) {
|
||||
await Storage.writeJSON("session/message/" + msg.sessionID + "/" + msg.id, msg)
|
||||
await StorageNext.write(["message", msg.sessionID, msg.id], msg)
|
||||
Bus.publish(MessageV2.Event.Updated, {
|
||||
info: msg,
|
||||
})
|
||||
}
|
||||
|
||||
async function updatePart(part: MessageV2.Part) {
|
||||
await Storage.writeJSON(["session", "part", part.sessionID, part.messageID, part.id].join("/"), part)
|
||||
await StorageNext.write(["part", part.messageID, part.id], part)
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part,
|
||||
})
|
||||
@@ -403,7 +417,7 @@ export namespace Session {
|
||||
const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID)
|
||||
msgs = preserve
|
||||
for (const msg of remove) {
|
||||
await Storage.remove(`session/message/${input.sessionID}/${msg.info.id}`)
|
||||
await StorageNext.remove(["message", input.sessionID, msg.info.id])
|
||||
await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, messageID: msg.info.id })
|
||||
}
|
||||
const last = preserve.at(-1)
|
||||
@@ -412,7 +426,7 @@ export namespace Session {
|
||||
const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID)
|
||||
last.parts = preserveParts
|
||||
for (const part of removeParts) {
|
||||
await Storage.remove(`session/part/${input.sessionID}/${last.info.id}/${part.id}`)
|
||||
await StorageNext.remove(["part", last.info.id, part.id])
|
||||
await Bus.publish(MessageV2.Event.PartRemoved, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: last.info.id,
|
||||
@@ -1164,7 +1178,7 @@ export namespace Session {
|
||||
error: assistantMsg.error,
|
||||
})
|
||||
}
|
||||
const p = await getParts(assistantMsg.sessionID, assistantMsg.id)
|
||||
const p = await getParts(assistantMsg.id)
|
||||
for (const part of p) {
|
||||
if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") {
|
||||
updatePart({
|
||||
|
||||
78
packages/opencode/src/storage/storage-next.ts
Normal file
78
packages/opencode/src/storage/storage-next.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Global } from "../global"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Lock } from "../util/lock"
|
||||
|
||||
export namespace StorageNext {
|
||||
const log = Log.create({ service: "storage" })
|
||||
|
||||
type Migration = (dir: string) => Promise<void>
|
||||
|
||||
const MIGRATIONS: Migration[] = []
|
||||
|
||||
const state = lazy(async () => {
|
||||
const dir = path.join(Global.Path.data, "storage")
|
||||
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 read<T>(key: string[]) {
|
||||
const dir = await state().then((x) => x.dir)
|
||||
const target = path.join(dir, ...key) + ".json"
|
||||
using _ = await Lock.read(target)
|
||||
return Bun.file(target).json() as Promise<T>
|
||||
}
|
||||
|
||||
export async function update<T>(key: string[], fn: (draft: T) => void) {
|
||||
const dir = await state().then((x) => x.dir)
|
||||
const target = path.join(dir, ...key) + ".json"
|
||||
using _ = await Lock.write("storage")
|
||||
const content = await Bun.file(target).json()
|
||||
fn(content)
|
||||
await Bun.write(target, JSON.stringify(content, null, 2))
|
||||
return content as T
|
||||
}
|
||||
|
||||
export async function write<T>(key: string[], content: T) {
|
||||
const dir = await state().then((x) => x.dir)
|
||||
const target = path.join(dir, ...key) + ".json"
|
||||
using _ = await Lock.write("storage")
|
||||
await Bun.write(target, JSON.stringify(content, null, 2))
|
||||
}
|
||||
|
||||
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((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
|
||||
result.sort()
|
||||
return result
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
98
packages/opencode/src/util/lock.ts
Normal file
98
packages/opencode/src/util/lock.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export namespace Lock {
|
||||
const locks = new Map<
|
||||
string,
|
||||
{
|
||||
readers: number
|
||||
writer: boolean
|
||||
waitingReaders: (() => void)[]
|
||||
waitingWriters: (() => void)[]
|
||||
}
|
||||
>()
|
||||
|
||||
function get(key: string) {
|
||||
if (!locks.has(key)) {
|
||||
locks.set(key, {
|
||||
readers: 0,
|
||||
writer: false,
|
||||
waitingReaders: [],
|
||||
waitingWriters: [],
|
||||
})
|
||||
}
|
||||
return locks.get(key)!
|
||||
}
|
||||
|
||||
function process(key: string) {
|
||||
const lock = locks.get(key)
|
||||
if (!lock || lock.writer || lock.readers > 0) return
|
||||
|
||||
// Prioritize writers to prevent starvation
|
||||
if (lock.waitingWriters.length > 0) {
|
||||
const nextWriter = lock.waitingWriters.shift()!
|
||||
nextWriter()
|
||||
return
|
||||
}
|
||||
|
||||
// Wake up all waiting readers
|
||||
while (lock.waitingReaders.length > 0) {
|
||||
const nextReader = lock.waitingReaders.shift()!
|
||||
nextReader()
|
||||
}
|
||||
|
||||
// Clean up empty locks
|
||||
if (lock.readers === 0 && !lock.writer && lock.waitingReaders.length === 0 && lock.waitingWriters.length === 0) {
|
||||
locks.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function read(key: string): Promise<Disposable> {
|
||||
const lock = get(key)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!lock.writer && lock.waitingWriters.length === 0) {
|
||||
lock.readers++
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.readers--
|
||||
process(key)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
lock.waitingReaders.push(() => {
|
||||
lock.readers++
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.readers--
|
||||
process(key)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function write(key: string): Promise<Disposable> {
|
||||
const lock = get(key)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!lock.writer && lock.readers === 0) {
|
||||
lock.writer = true
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.writer = false
|
||||
process(key)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
lock.waitingWriters.push(() => {
|
||||
lock.writer = true
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.writer = false
|
||||
process(key)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
63
specs/project.md
Normal file
63
specs/project.md
Normal file
@@ -0,0 +1,63 @@
|
||||
## project
|
||||
|
||||
goal is to let a single instance of opencode be able to run sessions for
|
||||
multiple projects and different worktrees per project
|
||||
|
||||
### api
|
||||
|
||||
```
|
||||
GET /project -> Project[]
|
||||
|
||||
POST /project/init -> Project
|
||||
|
||||
|
||||
GET /project/:projectID/session -> Session[]
|
||||
|
||||
GET /project/:projectID/session/:sessionID -> Session
|
||||
|
||||
POST /project/:projectID/session -> Session
|
||||
{
|
||||
id?: string
|
||||
parentID?: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
DELETE /project/:projectID/session/:sessionID
|
||||
|
||||
POST /project/:projectID/session/:sessionID/init
|
||||
|
||||
POST /project/:projectID/session/:sessionID/abort
|
||||
|
||||
POST /project/:projectID/session/:sessionID/share
|
||||
|
||||
DELETE /project/:projectID/session/:sessionID/share
|
||||
|
||||
POST /project/:projectID/session/:sessionID/compact
|
||||
|
||||
GET /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] }[]
|
||||
|
||||
GET /project/:projectID/session/:sessionID/message/:messageID -> { info: Message, parts: Part[] }
|
||||
|
||||
POST /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] }
|
||||
|
||||
POST /project/:projectID/session/:sessionID/revert -> Session
|
||||
|
||||
POST /project/:projectID/session/:sessionID/unrevert -> Session
|
||||
|
||||
POST /project/:projectID/session/:sessionID/permission/:permissionID -> Session
|
||||
|
||||
GET /project/:projectID/session/:sessionID/find/file -> string[]
|
||||
|
||||
GET /project/:projectID/session/:sessionID/file -> { type: "raw" | "patch", content: string }
|
||||
|
||||
GET /project/:projectID/session/:sessionID/file/status -> File[]
|
||||
|
||||
POST /log
|
||||
|
||||
// These are awkward
|
||||
|
||||
GET /provider?directory=<resolve path> -> Provider
|
||||
GET /config?directory=<resolve path> -> Config // think only tui uses this?
|
||||
GET /agent?directory=<resolve path> -> Mode
|
||||
|
||||
```
|
||||
Reference in New Issue
Block a user