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
72 changes: 72 additions & 0 deletions packages/opencode/src/session/file-change.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import path from "path"
import { Effect } from "effect"
import { Storage } from "@/storage/storage"
import { SessionID } from "./schema"
import { Hash } from "@opencode-ai/core/util/hash"

export const metadataKey = "__opencode_file_baselines"
const maxBytes = 1024 * 1024

export interface Input {
file: string
existed: boolean
content: string
bom: boolean
}

export interface Baseline extends Omit<Input, "file" | "content"> {
path: string
content: string | undefined
}

export const persist = Effect.fn("SessionFileChange.persist")(function* (input: {
storage: Storage.Interface
sessionID: SessionID
directory: string
metadata: Record<string, unknown>
}) {
const raw = input.metadata[metadataKey]
const metadata = { ...input.metadata }
delete metadata[metadataKey]
if (!Array.isArray(raw)) return metadata

const baselines = raw.flatMap((item): Baseline[] => {
if (!item || typeof item !== "object") return []
const record = item as Record<string, unknown>
if (
typeof record.file !== "string" ||
typeof record.existed !== "boolean" ||
typeof record.content !== "string" ||
typeof record.bom !== "boolean"
) {
return []
}
const relative = path.relative(input.directory, record.file).replaceAll("\\", "/")
if (!relative || relative === ".." || relative.startsWith("../")) return []
return [
{
path: relative,
existed: record.existed,
content: Buffer.byteLength(record.content) <= maxBytes ? record.content : undefined,
bom: record.bom,
},
]
})
yield* Effect.forEach(baselines, (baseline) => {
const key = ["session_file", input.sessionID, Hash.fast(baseline.path)]
return input.storage.read<Baseline>(key).pipe(
Effect.catch(() => input.storage.write(key, baseline).pipe(Effect.orDie, Effect.as(baseline))),
Effect.ignore,
)
})
return metadata
})

export const list = Effect.fn("SessionFileChange.list")(function* (storage: Storage.Interface, sessionID: SessionID) {
const keys = yield* storage.list(["session_file", sessionID]).pipe(Effect.orDie)
return yield* Effect.forEach(keys, (key) => storage.read<Baseline>(key).pipe(Effect.orDie), {
concurrency: "unbounded",
})
})

export * as SessionFileChange from "./file-change"
12 changes: 11 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { isRecord } from "@/util/record"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { Usage, type LLMEvent } from "@opencode-ai/llm"
import { Storage } from "@/storage/storage"
import { SessionFileChange } from "./file-change"

const DOOM_LOOP_THRESHOLD = 3
export type Result = "compact" | "stop" | "continue"
Expand Down Expand Up @@ -88,6 +90,7 @@ const layer = Layer.effect(
const llm = yield* LLM.Service
const permission = yield* Permission.Service
const plugin = yield* Plugin.Service
const storage = yield* Storage.Service
const summary = yield* SessionSummary.Service
const scope = yield* Scope.Scope
const status = yield* SessionStatus.Service
Expand Down Expand Up @@ -168,13 +171,19 @@ const layer = Layer.effect(
) {
const match = yield* readToolCall(toolCallID)
if (!match || match.part.state.status !== "running") return
const metadata = yield* SessionFileChange.persist({
storage,
sessionID: match.part.sessionID,
directory: (yield* session.get(match.part.sessionID).pipe(Effect.orDie)).directory,
metadata: output.metadata,
})
yield* session.updatePart({
...match.part,
state: {
status: "completed",
input: match.part.state.input,
output: output.output,
metadata: output.metadata,
metadata,
title: output.title,
time: { start: match.part.state.time.start, end: Date.now() },
attachments: output.attachments,
Expand Down Expand Up @@ -712,6 +721,7 @@ export const node = LayerNode.make({
Image.node,
EventV2Bridge.node,
Database.node,
Storage.node,
],
})

Expand Down
112 changes: 96 additions & 16 deletions packages/opencode/src/session/summary.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect, Layer, Context, Schema } from "effect"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Snapshot } from "@/snapshot"
import { Session } from "./session"
import { SessionID, MessageID } from "./schema"
import { Config } from "@/config/config"
import path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { createTwoFilesPatch, diffLines } from "diff"
import { Storage } from "@/storage/storage"
import { SessionFileChange } from "./file-change"
import * as Bom from "@/util/bom"

function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
Expand Down Expand Up @@ -76,8 +81,9 @@ const layer = Layer.effect(
Effect.gen(function* () {
const sessions = yield* Session.Service
const snapshot = yield* Snapshot.Service
const events = yield* EventV2Bridge.Service
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const storage = yield* Storage.Service

const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionV1.WithParts[] }) {
let from: string | undefined
Expand All @@ -103,15 +109,6 @@ const layer = Layer.effect(
sessionID: SessionID
messageID: MessageID
}) {
yield* sessions.setSummary({
sessionID: input.sessionID,
summary: {
additions: 0,
deletions: 0,
files: 0,
},
})
yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: [] })
if ((yield* config.get()).snapshot === false) return
const all = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)
if (!all.length) return
Expand All @@ -127,10 +124,29 @@ const layer = Layer.effect(
})

const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
if (!input.messageID) return []
const message = (yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)).find(
(item) => item.info.id === input.messageID,
)
const messages = yield* sessions.messages({ sessionID: input.sessionID }).pipe(Effect.orDie)
if (!input.messageID) {
const directory = (yield* sessions.get(input.sessionID).pipe(Effect.orDie)).directory
const tracked = yield* SessionFileChange.list(storage, input.sessionID)
if (tracked.length) {
const unique = new Map<string, SessionFileChange.Baseline>()
for (const baseline of tracked) {
if (!unique.has(baseline.path)) unique.set(baseline.path, baseline)
}
const current = yield* Effect.forEach(unique.values(), (baseline) =>
trackedDiff(fs, baseline, directory),
).pipe(Effect.map((items) => items.flatMap((item) => (item ? [item] : []))))
const paths = new Set(unique.keys())
return [
...editedFiles(messages, directory).filter((item) => item.file && !paths.has(item.file)),
...current,
].toSorted((a, b) => (a.file ?? "").localeCompare(b.file ?? ""))
}
const snapshotDiff = (yield* config.get()).snapshot === false ? [] : yield* computeDiff({ messages })
if (snapshotDiff.length) return snapshotDiff
return editedFiles(messages, directory)
}
const message = messages.find((item) => item.info.id === input.messageID)
if (!message || message.info.role !== "user") return []
const diffs = message.info.summary?.diffs ?? []
return diffs.map((item) => {
Expand All @@ -154,7 +170,71 @@ export type DiffInput = Schema.Schema.Type<typeof DiffInput>
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [Session.node, Snapshot.node, EventV2Bridge.node, Config.node],
deps: [Session.node, Snapshot.node, Config.node, FSUtil.node, Storage.node],
})

const trackedDiff = Effect.fnUntraced(function* (
fs: FSUtil.Interface,
baseline: SessionFileChange.Baseline,
directory: string,
) {
if (baseline.content === undefined) return
const file = path.join(directory, baseline.path)
const exists = yield* fs.existsSafe(file)
const current = exists ? yield* Bom.readFile(fs, file).pipe(Effect.catch(() => Effect.succeed(undefined))) : undefined
if (exists && current === undefined) return
const content = current?.text ?? ""
const bom = current?.bom ?? false
if (baseline.existed === exists && baseline.content === content && baseline.bom === bom) return
const changes = diffLines(baseline.content, content)
return {
file: baseline.path,
patch: createTwoFilesPatch(file, file, baseline.content, content),
additions: changes.reduce((sum, change) => sum + (change.added ? change.count : 0), 0),
deletions: changes.reduce((sum, change) => sum + (change.removed ? change.count : 0), 0),
status: !baseline.existed ? "added" : !exists ? "deleted" : "modified",
} satisfies Snapshot.FileDiff
})

function editedFiles(messages: SessionV1.WithParts[], directory: string) {
const files = new Map<string, Snapshot.FileDiff>()
for (const part of messages.flatMap((message) => message.parts)) {
if (part.type !== "tool" || part.state.status !== "completed") continue
const metadata = part.state.metadata
if (!metadata || typeof metadata !== "object") continue
const records = Array.isArray(metadata.filediffs)
? metadata.filediffs
: metadata.filediff
? [metadata.filediff]
: Array.isArray(metadata.files)
? metadata.files
: []
for (const record of records) {
if (!record || typeof record !== "object") continue
const source = record as Record<string, unknown>
const file = [source.file, source.filePath, source.movePath, source.relativePath].find(
(value): value is string => typeof value === "string",
)
if (!file) continue
const relative = path.isAbsolute(file) ? path.relative(directory, file).replaceAll("\\", "/") : file
if (relative.startsWith("../") || relative === "..") continue
const display = relative
const previous = files.get(display)
files.set(display, {
file: display,
patch: typeof source.patch === "string" ? source.patch : previous?.patch,
additions: (previous?.additions ?? 0) + (typeof source.additions === "number" ? source.additions : 0),
deletions: (previous?.deletions ?? 0) + (typeof source.deletions === "number" ? source.deletions : 0),
status:
source.status === "added" || source.type === "add"
? "added"
: source.status === "deleted" || source.type === "delete"
? "deleted"
: "modified",
})
}
}
return [...files.values()]
}

export * as SessionSummary from "./summary"
15 changes: 15 additions & 0 deletions packages/opencode/src/tool/apply_patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import DESCRIPTION from "./apply_patch.txt"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Format } from "../format"
import * as Bom from "@/util/bom"
import { SessionFileChange } from "@/session/file-change"

export const Parameters = Schema.Struct({
patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }),
Expand Down Expand Up @@ -141,6 +142,11 @@ export const ApplyPatchTool = Tool.define(

const movePath = hunk.move_path ? path.resolve(instance.directory, hunk.move_path) : undefined
yield* assertExternalDirectoryEffect(ctx, movePath)
if (movePath && (yield* afs.existsSafe(movePath))) {
return yield* Effect.fail(
new Error(`apply_patch verification failed: Move destination exists: ${movePath}`),
)
}

fileChanges.push({
filePath,
Expand Down Expand Up @@ -297,6 +303,15 @@ export const ApplyPatchTool = Tool.define(
metadata: {
diff: totalDiff,
files,
[SessionFileChange.metadataKey]: fileChanges.flatMap((change) => [
{
file: change.filePath,
existed: change.type !== "add",
content: change.oldContent,
bom: change.type === "add" ? false : change.bom,
},
...(change.movePath ? [{ file: change.movePath, existed: false, content: "", bom: false }] : []),
]),
diagnostics,
},
output,
Expand Down
9 changes: 8 additions & 1 deletion packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
import { FSUtil } from "@opencode-ai/core/fs-util"
import * as Bom from "@/util/bom"
import { SessionFileChange } from "@/session/file-change"

function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
Expand Down Expand Up @@ -85,17 +86,20 @@ export const EditTool = Tool.define(
let diff = ""
let contentOld = ""
let contentNew = ""
let existed = false
let bom = false
yield* lock(filePath).withPermits(1)(
Effect.gen(function* () {
if (params.oldString === "") {
const existed = yield* afs.existsSafe(filePath)
existed = yield* afs.existsSafe(filePath)
if (existed) {
throw new Error(
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
)
}
const next = Bom.split(params.newString)
const desiredBom = next.bom
bom = false
contentOld = ""
contentNew = next.text
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
Expand Down Expand Up @@ -124,6 +128,8 @@ export const EditTool = Tool.define(
if (!info) throw new Error(`File ${filePath} not found`)
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
const source = yield* Bom.readFile(afs, filePath)
existed = true
bom = source.bom
contentOld = source.text

const ending = detectLineEnding(contentOld)
Expand Down Expand Up @@ -205,6 +211,7 @@ export const EditTool = Tool.define(
diagnostics,
diff,
filediff,
[SessionFileChange.metadataKey]: [{ file: filePath, existed, content: contentOld, bom }],
},
title: `${path.relative(instance.worktree, filePath)}`,
output,
Expand Down
16 changes: 14 additions & 2 deletions packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as path from "path"
import { Effect } from "effect"
import * as Tool from "./tool"
import { LSP } from "@/lsp/lsp"
import { createTwoFilesPatch } from "diff"
import { createTwoFilesPatch, diffLines } from "diff"
import DESCRIPTION from "./write.txt"
import { EventV2Bridge } from "@/event-v2-bridge"
import { FileSystem } from "@opencode-ai/core/filesystem"
Expand All @@ -14,6 +14,7 @@ import { InstanceState } from "@/effect/instance-state"
import { trimDiff } from "./edit"
import { assertExternalDirectoryEffect } from "./external-directory"
import * as Bom from "@/util/bom"
import { SessionFileChange } from "@/session/file-change"

const MAX_PROJECT_DIAGNOSTICS_FILES = 5

Expand Down Expand Up @@ -49,8 +50,15 @@ export const WriteTool = Tool.define(
const desiredBom = source.bom || next.bom
const contentOld = source.text
const contentNew = next.text

const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
const changes = diffLines(contentOld, contentNew)
const filediff = {
file: filepath,
patch: diff,
additions: changes.reduce((sum, change) => sum + (change.added ? change.count : 0), 0),
deletions: changes.reduce((sum, change) => sum + (change.removed ? change.count : 0), 0),
status: exists ? ("modified" as const) : ("added" as const),
}
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(instance.worktree, filepath)],
Expand Down Expand Up @@ -95,6 +103,10 @@ export const WriteTool = Tool.define(
diagnostics,
filepath,
exists: exists,
filediff,
[SessionFileChange.metadataKey]: [
{ file: filepath, existed: exists, content: contentOld, bom: source.bom },
],
},
output,
}
Expand Down
Loading
Loading