Skip to content
Closed
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
95 changes: 95 additions & 0 deletions packages/opencode/src/history/extract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { SessionV1 } from "@opencode-ai/core/v1/session"

export type Kind = "user_text" | "assistant_text" | "tool_input" | "tool_error" | "reasoning" | "tool_output"

export const ALL_KINDS: ReadonlyArray<Kind> = [
"user_text",
"assistant_text",
"tool_input",
"tool_error",
"reasoning",
"tool_output",
]

export const DEFAULT_KINDS: ReadonlyArray<Kind> = ["user_text", "assistant_text", "tool_input", "tool_error"]

export type Extracted = { kind: Kind; body: string; tool_name: string | null }

/**
* Flatten one message part into a searchable text body tagged by kind, or null
* if the part type/state isn't indexable or its kind is disabled.
*/
export function extract(
part: SessionV1.Part,
messageRole: "user" | "assistant",
enabledKinds: ReadonlySet<Kind>,
): Extracted | null {
switch (part.type) {
case "text": {
const kind: Kind = messageRole === "user" ? "user_text" : "assistant_text"
if (!enabledKinds.has(kind)) return null
if (!part.text) return null
return { kind, body: part.text, tool_name: null }
}
case "reasoning": {
if (!enabledKinds.has("reasoning")) return null
if (!part.text) return null
return { kind: "reasoning", body: part.text, tool_name: null }
}
case "tool": {
const state = part.state
if (state.status === "pending" || state.status === "running") return null

if (state.status === "error" && enabledKinds.has("tool_error")) {
return {
kind: "tool_error",
body: `${part.tool} ${JSON.stringify(state.input ?? {})} ${state.error ?? ""}`,
tool_name: part.tool,
}
}
if (state.status === "completed" && enabledKinds.has("tool_output")) {
return {
kind: "tool_output",
body: `${part.tool} ${JSON.stringify(state.input ?? {})} ${JSON.stringify(state.output ?? "")}`,
tool_name: part.tool,
}
}
if (enabledKinds.has("tool_input")) {
return {
kind: "tool_input",
body: `${part.tool} ${JSON.stringify(state.input ?? {})}`,
tool_name: part.tool,
}
}
return null
}
default:
return null
}
}

/** Render a part to human-readable text for the `around` context view. */
export function renderPart(part: SessionV1.Part): { type: string; tool_name: string | null; text: string } {
switch (part.type) {
case "text":
case "reasoning":
return { type: part.type, tool_name: null, text: part.text ?? "" }
case "tool": {
const state = part.state
const input = "input" in state ? state.input : {}
const tail =
state.status === "error"
? `error: ${state.error ?? ""}`
: state.status === "completed"
? `output: ${JSON.stringify(state.output ?? "")}`
: `status: ${state.status}`
return {
type: "tool",
tool_name: part.tool ?? null,
text: `tool: ${part.tool ?? ""}\ninput: ${JSON.stringify(input ?? {})}\n${tail}`,
}
}
default:
return { type: part.type, tool_name: null, text: `[${part.type}]` }
}
}
1 change: 1 addition & 0 deletions packages/opencode/src/history/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as History from "./service"
181 changes: 181 additions & 0 deletions packages/opencode/src/history/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Context, Effect, Layer } from "effect"
import type { SessionV1 } from "@opencode-ai/core/v1/session"
import { Session } from "@/session/session"
import { InstanceState } from "@/effect/instance-state"
import type { SessionID } from "../session/schema"
import * as Bm25 from "../memory/bm25"
import { ALL_KINDS, extract, renderPart, type Kind } from "./extract"

export type SearchHit = {
part_id: string
session_id: string
message_id: string
project_id: string
kind: Kind
tool_name: string | null
snippet: string
score: number
time_created: number
}

export type MessagePart = {
part_id: string
type: string
role: "user" | "assistant"
tool_name: string | null
text: string
}

export type MessageContext = {
message_id: string
matched: boolean
time_created: number
parts: MessagePart[]
}

export interface SearchInput {
query: string
scope?: "project" | "global"
session_id?: string
kind?: Kind | Kind[]
tool_name?: string
time_after?: number
time_before?: number
limit?: number
}

export interface AroundInput {
message_id: string
session_id?: string
before?: number
after?: number
}

export interface Interface {
readonly search: (input: SearchInput) => Effect.Effect<SearchHit[]>
readonly around: (input: AroundInput) => Effect.Effect<{ session_id: string; messages: MessageContext[] }>
}

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

const HARD_CAP = 50

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

const sessionIds = Effect.fn("History.sessionIds")(function* (scope: "project" | "global") {
if (scope === "global") {
const list = yield* session.listGlobal().pipe(Effect.catch(() => Effect.succeed([])))
return list.map((s) => s.id as SessionID)
}
const list = yield* session.list().pipe(Effect.catch(() => Effect.succeed([])))
return list.map((s) => s.id)
})

const loadMessages = Effect.fn("History.loadMessages")(function* (sid: SessionID) {
return yield* session
.messages({ sessionID: sid })
.pipe(Effect.catch(() => Effect.succeed([] as SessionV1.WithParts[])))
})

const search = Effect.fn("History.search")(function* (input: SearchInput) {
const limit = Math.min(input.limit ?? 10, HARD_CAP)
const scope = input.scope ?? "project"
const enabled = new Set<Kind>(
input.kind ? (Array.isArray(input.kind) ? input.kind : [input.kind]) : ALL_KINDS,
)

const ctx = yield* InstanceState.context
let ids = yield* sessionIds(scope)
if (input.session_id) ids = ids.filter((id) => id === input.session_id)

const docs: Bm25.Doc[] = []
const meta = new Map<string, Omit<SearchHit, "part_id" | "snippet" | "score">>()

for (const sid of ids) {
const msgs = yield* loadMessages(sid)
for (const m of msgs) {
const role = m.info.role
const created = m.info.time.created
if (input.time_after !== undefined && created < input.time_after) continue
if (input.time_before !== undefined && created > input.time_before) continue
for (const part of m.parts) {
const ex = extract(part, role, enabled)
if (!ex) continue
if (input.tool_name && ex.tool_name !== input.tool_name) continue
const partId = part.id
docs.push({ path: partId, body: ex.body })
meta.set(partId, {
session_id: sid,
message_id: m.info.id,
project_id: scope === "project" ? ctx.project.id : "",
kind: ex.kind,
tool_name: ex.tool_name,
time_created: created,
})
}
}
}

const ranked = Bm25.search(docs, input.query, { limit })
return ranked.map((r) => {
const mm = meta.get(r.path)!
return { part_id: r.path, ...mm, snippet: r.snippet, score: r.score }
})
})

const locateSession = Effect.fn("History.locateSession")(function* (messageId: string) {
for (const scope of ["project", "global"] as const) {
const ids = yield* sessionIds(scope)
for (const sid of ids) {
const msgs = yield* loadMessages(sid)
if (msgs.some((m) => m.info.id === messageId)) return sid
}
}
return undefined
})

const around = Effect.fn("History.around")(function* (input: AroundInput) {
const before = input.before ?? 5
const after = input.after ?? 5

const sessionId = (input.session_id as SessionID | undefined) ?? (yield* locateSession(input.message_id))
if (!sessionId) return { session_id: "", messages: [] as MessageContext[] }

const msgs = yield* loadMessages(sessionId)
const idx = msgs.findIndex((m) => m.info.id === input.message_id)
if (idx === -1) return { session_id: sessionId, messages: [] as MessageContext[] }

const start = Math.max(0, idx - before)
const end = Math.min(msgs.length, idx + after + 1)
const out: MessageContext[] = msgs.slice(start, end).map((m) => ({
message_id: m.info.id,
matched: m.info.id === input.message_id,
time_created: m.info.time.created,
parts: m.parts.map((p) => {
const r = renderPart(p)
return {
part_id: p.id,
type: r.type,
role: m.info.role,
tool_name: r.tool_name,
text: r.text,
}
}),
}))

return { session_id: sessionId, messages: out }
})

return Service.of({ search, around })
}),
)

export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(Session.defaultLayer)))

export const node = LayerNode.make({ service: Service, layer, deps: [Session.node] })

export * as History from "./service"
49 changes: 49 additions & 0 deletions packages/opencode/src/lsp/lsp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ export interface Interface {
readonly definition: (input: LocInput) => Effect.Effect<any[]>
readonly references: (input: LocInput) => Effect.Effect<any[]>
readonly implementation: (input: LocInput) => Effect.Effect<any[]>
readonly rename: (input: LocInput & { newName: string }) => Effect.Effect<any>
readonly codeAction: (input: {
file: string
line?: number
character?: number
endLine?: number
endCharacter?: number
}) => Effect.Effect<any[]>
readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]>
readonly workspaceSymbol: (query: string) => Effect.Effect<Symbol[]>
readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
Expand Down Expand Up @@ -422,6 +430,45 @@ export const layer = Layer.effect(
return results.flat().filter(Boolean)
})

const rename = Effect.fn("LSP.rename")(function* (input: LocInput & { newName: string }) {
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/rename", {
textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character },
newName: input.newName,
})
.catch(() => null),
)
return results.find(Boolean) ?? null
})

const codeAction = Effect.fn("LSP.codeAction")(function* (input: {
file: string
line?: number
character?: number
endLine?: number
endCharacter?: number
}) {
const startLine = input.line ?? 0
const startChar = input.character ?? 0
const endLine = input.endLine ?? startLine
const endChar = input.endCharacter ?? startChar
const results = yield* run(input.file, (client) =>
client.connection
.sendRequest("textDocument/codeAction", {
textDocument: { uri: pathToFileURL(input.file).href },
range: {
start: { line: startLine, character: startChar },
end: { line: endLine, character: endChar },
},
context: { diagnostics: [] },
})
.catch(() => []),
)
return results.flat().filter(Boolean)
})

const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
const file = fileURLToPath(uri)
const results = yield* run(file, (client) =>
Expand Down Expand Up @@ -487,6 +534,8 @@ export const layer = Layer.effect(
definition,
references,
implementation,
rename,
codeAction,
documentSymbol,
workspaceSymbol,
prepareCallHierarchy,
Expand Down
Loading
Loading