Skip to content
Merged
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
"web-tree-sitter": "0.25.10",
"ws": "8.21.0",
"xdg-basedir": "5.1.0",
"yaml": "2.9.0",
"yargs": "18.0.0",
"zod": "catalog:"
},
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const Default = {
REVIEW: "review",
GOAL: "goal",
SUBGOAL: "subgoal",
MEMORY: "memory",
DAG_FLOW: "dag-flow",
IMPORT_HOOKS: "import-claude-hooks",
CREATE_HOOK: "create-hook",
Expand Down Expand Up @@ -107,6 +108,13 @@ export const layer = Layer.effect(
template: "",
hints: ["$ARGUMENTS"],
}
commands[Default.MEMORY] = {
name: Default.MEMORY,
description: "启用或关闭项目 MEMORY [on|off]",
source: "command",
template: "",
hints: ["$ARGUMENTS"],
}
commands[Default.DAG_FLOW] = {
name: Default.DAG_FLOW,
description: CommandPlugin.DagFlowDescription,
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { Dag } from "@/dag/dag"
import { DagStore } from "@opencode-ai/core/dag/store"
import { DagLoop } from "@/dag/runtime/loop"
import { DagSummaryPublisher } from "@/dag/runtime/summary-publisher"
import { Memory } from "@/memory/memory"

export const AppLayer = Layer.mergeAll(
Layer.mergeAll(
Expand All @@ -83,6 +84,7 @@ export const AppLayer = Layer.mergeAll(
Permission.defaultLayer,
Todo.defaultLayer,
Goal.defaultLayer,
Memory.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
BackgroundJob.defaultLayer,
Expand Down
146 changes: 146 additions & 0 deletions packages/opencode/src/memory/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
export * as MemoryConfig from "./config"

import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { dirname, join } from "node:path"
import { parse, type ParseError } from "jsonc-parser"
import { MemoryFile } from "./file"
import { MemorySchema } from "./schema"

export type Loaded = {
readonly config: MemorySchema.Config
readonly path: string
readonly level: "project" | "global"
}

export interface Interface {
readonly load: (projectDir: string) => Effect.Effect<Loaded | undefined, FSUtil.Error>
readonly loadGlobal: () => Effect.Effect<Loaded | undefined, FSUtil.Error>
readonly writeProject: (
projectDir: string,
config: MemorySchema.Config,
existingPath?: string,
) => Effect.Effect<void, FSUtil.Error>
readonly writeGlobal: (config: MemorySchema.Config, existingPath?: string) => Effect.Effect<boolean, FSUtil.Error>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/MemoryConfig") {}

export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service

const readFirst = Effect.fnUntraced(function* (paths: string[]) {
for (const path of paths) {
const text = yield* fs.readFileStringSafe(path)
if (text !== undefined) return { path, text }
}
return undefined
})

const readConfig = Effect.fnUntraced(function* (found: { path: string; text: string }) {
const decoded = decode(found.text)
if (Option.isNone(decoded)) {
yield* Effect.logWarning("memory config is invalid — ignoring", { path: found.path })
return undefined
}
if (decoded.value.topic_limit === decoded.value.topic_limit_floor) return decoded.value
const config = MemorySchema.updateConfig(decoded.value, { topic_limit_floor: decoded.value.topic_limit })
yield* MemoryFile.atomicWrite(fs, found.path, serialize(config))
return config
})

const load = Effect.fn("MemoryConfig.load")(function* (projectDir: string) {
const found = yield* readFirst(candidates(projectDir))
if (!found) return undefined
const config = yield* readConfig(found)
if (!config) return undefined
return {
config,
path: found.path,
level: projectCandidates(projectDir).includes(found.path) ? ("project" as const) : ("global" as const),
}
})

const loadGlobal = Effect.fn("MemoryConfig.loadGlobal")(function* () {
const found = yield* readFirst(globalCandidates())
if (!found) return undefined
const config = yield* readConfig(found)
return config ? { config, path: found.path, level: "global" as const } : undefined
})

const writeProject = Effect.fn("MemoryConfig.writeProject")(function* (
projectDir: string,
config: MemorySchema.Config,
existingPath?: string,
) {
yield* MemoryFile.atomicWrite(fs, existingPath ?? projectPath(projectDir), serialize(config))
})

const writeGlobal = Effect.fn("MemoryConfig.writeGlobal")(function* (
config: MemorySchema.Config,
existingPath?: string,
) {
if (existingPath && globalCandidates().includes(existingPath)) {
yield* MemoryFile.atomicWrite(fs, existingPath, serialize(config))
return true
}
const file = join(globalConfigDir(), "memory.jsonc")
const found = yield* readFirst(globalCandidates())
if (found) {
if (yield* readConfig(found)) return false
yield* MemoryFile.atomicWrite(fs, found.path, serialize(config))
return true
}
yield* fs.makeDirectory(dirname(file), { recursive: true })
return yield* fs.writeFileString(file, serialize(config), { flag: "wx" }).pipe(
Effect.as(true),
Effect.catchReason("PlatformError", "AlreadyExists", () => Effect.succeed(false)),
)
})

return Service.of({ load, loadGlobal, writeProject, writeGlobal })
}),
)

export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer))

export const node = LayerNode.make(layer, [FSUtil.node])

export function projectPath(projectDir: string) {
return join(projectDir, ".opencode", "memory.jsonc")
}

export function candidates(projectDir: string) {
return [...projectCandidates(projectDir), ...globalCandidates()]
}

export function globalConfigDir() {
return Flag.OPENCODE_CONFIG_DIR ?? Global.Path.config
}

function projectCandidates(projectDir: string) {
return [join(projectDir, ".opencode", "memory.jsonc"), join(projectDir, ".opencode", "memory.json")]
}

function globalCandidates() {
return [join(globalConfigDir(), "memory.jsonc"), join(globalConfigDir(), "memory.json")]
}

function serialize(config: MemorySchema.Config) {
return JSON.stringify(config, null, 2) + "\n"
}

function decode(text: string) {
const errors: ParseError[] = []
const value = parse(text, errors, { allowTrailingComma: true })
if (errors.length > 0) return Option.none<MemorySchema.Config>()
const decoded = Schema.decodeUnknownOption(MemorySchema.Config)(value ?? {})
if (Option.isNone(decoded) || decoded.value.topic_limit < decoded.value.topic_limit_floor)
return Option.none<MemorySchema.Config>()
return decoded
}
22 changes: 22 additions & 0 deletions packages/opencode/src/memory/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export * as MemoryFile from "./file"

import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect } from "effect"
import { randomUUID } from "node:crypto"
import { dirname } from "node:path"

export const atomicWrite = Effect.fn("MemoryFile.atomicWrite")(function* (
fs: FSUtil.Interface,
file: string,
content: string,
) {
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`
yield* Effect.gen(function* () {
yield* fs.makeDirectory(dirname(file), { recursive: true })
yield* fs.writeFileString(temporary, content)
yield* fs.rename(temporary, file)
}).pipe(
Effect.onError(() => fs.remove(temporary, { force: true }).pipe(Effect.ignore)),
Effect.uninterruptible,
)
})
Loading
Loading