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
4 changes: 4 additions & 0 deletions documentor/FORK.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion packages/opencode/src/acp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"

/** Messages read for a usage update: enough to hold the newest assistant message. */
const USAGE_UPDATE_MESSAGES = 20

export const AuthMethodID = "opencode-login"

export type Error = ACPError.Error
Expand Down Expand Up @@ -655,13 +658,18 @@ function makeUsageService(sdk: OpencodeClient) {
},
)

// documentor: reads the newest page only and reports no cost. The latest
// assistant message is always on it, and the session total would need every
// message of a long chat on each prompt; the ACP field is optional and
// DocuMentor reads only `used` and `size`.
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACP.promptUsage.sendUpdate")(function* (params) {
const messages = yield* request(
() =>
sdk.session.messages(
{
sessionID: params.sessionID,
directory: params.directory,
limit: USAGE_UPDATE_MESSAGES,
},
{ throwOnError: true },
),
Expand Down Expand Up @@ -692,7 +700,6 @@ function makeUsageService(sdk: OpencodeClient) {
sessionUpdate: "usage_update",
used: UsageService.contextTokens(message),
size,
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
},
})
.catch(() => {}),
Expand Down
52 changes: 29 additions & 23 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,34 +277,40 @@ const layer = Layer.effect(
if (!cfg.compaction?.prune) return
yield* Effect.logInfo("pruning")

const msgs = yield* session
.messages({ sessionID: input.sessionID })
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
if (!msgs) return

let total = 0
let pruned = 0
const toPrune: SessionV1.ToolPart[] = []
let turns = 0

loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user" && msg.info.answerable !== false) turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type !== "tool") continue
if (part.state.status !== "completed") continue
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total <= PRUNE_PROTECT) continue
pruned += estimate
toPrune.push(part)
}
}
// documentor: reads newest first and stops where the walk stops, instead of loading the
// whole session after every turn.
const found = yield* session
.scanNewestFirst({
sessionID: input.sessionID,
visit: (msg) => {
if (msg.info.role === "user" && msg.info.answerable !== false) turns++
if (turns < 2) return true
if (msg.info.role === "assistant" && msg.info.summary) return false
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type !== "tool") continue
if (part.state.status !== "completed") continue
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) return false
const estimate = Token.estimate(part.state.output)
total += estimate
if (total <= PRUNE_PROTECT) continue
pruned += estimate
toPrune.push(part)
}
return true
},
})
.pipe(
Effect.as(true),
Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(false)),
)
if (!found) return

yield* Effect.logInfo("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
Expand Down
62 changes: 49 additions & 13 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,29 +550,39 @@ export const get = Effect.fn("MessageV2.get")(function* (input: { sessionID: Ses
}
})

export function filterCompacted(msgs: Iterable<WithParts>) {
/**
* Reads messages newest first and keeps what the model still sees: everything
* back to the newest completed compaction, plus the tail that compaction
* retains. `push` answers false once no older message can matter.
*/
function compactedScanner() {
const result = [] as WithParts[]
const completed = new Set<string>()
let retain: MessageID | undefined
for (const msg of msgs) {
const push = (msg: WithParts): boolean => {
result.push(msg)
if (retain) {
if (msg.info.id === retain) break
continue
}
if (retain) return msg.info.id !== retain
if (msg.info.role === "user" && completed.has(msg.info.id)) {
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
if (!part) continue
if (!part.tail_start_id) break
if (!part) return true
if (!part.tail_start_id) return false
retain = part.tail_start_id
if (msg.info.id === retain) break
continue
return msg.info.id !== retain
}
if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))
break
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
completed.add(msg.info.parentID)
return true
}
return { push, finish: () => orderCompacted(result) }
}

export function filterCompacted(msgs: Iterable<WithParts>) {
const scanner = compactedScanner()
for (const msg of msgs) if (!scanner.push(msg)) break
return scanner.finish()
}

function orderCompacted(result: WithParts[]) {
result.reverse()
const compactionIndex = result.findLastIndex(
(msg) =>
Expand Down Expand Up @@ -603,8 +613,34 @@ export function filterCompacted(msgs: Iterable<WithParts>) {
return result
}

/**
* documentor: hands a session's messages to `visit` newest first, a page at a time, until it
* answers false or no older message is left. Fails with NotFoundError when the session does not exist.
*/
export const scanNewestFirst = Effect.fnUntraced(function* (input: {
sessionID: SessionID
visit: (message: WithParts) => boolean
}) {
let before: string | undefined
while (true) {
const next = yield* page({ sessionID: input.sessionID, limit: 50, before })
for (let i = next.items.length - 1; i >= 0; i--) {
const item = next.items[i]
if (item && !input.visit(item)) return
}
if (!next.more || !next.cursor) return
before = next.cursor
}
})

// documentor: reads back to the newest compaction and no further, so a long chat is not
// loaded in full on every step of the prompt loop.
export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) {
return filterCompacted(yield* stream(sessionID))
const scanner = compactedScanner()
yield* scanNewestFirst({ sessionID, visit: scanner.push }).pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.void),
)
return scanner.finish()
})

// filterCompacted reorders messages for model consumption
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,14 @@ export interface Interface {
readonly setWorkspace: (input: { sessionID: SessionID; workspaceID: Info["workspaceID"] }) => Effect.Effect<void>
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<SessionV1.WithParts[], NotFound>
/**
* documentor: hands messages to `visit` newest first, a page at a time, until it answers false
* or the session has no older message, so a caller that needs only recent history stops there.
*/
readonly scanNewestFirst: (input: {
sessionID: SessionID
visit: (message: SessionV1.WithParts) => boolean
}) => Effect.Effect<void, NotFound>
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
readonly remove: (sessionID: SessionID) => Effect.Effect<void, NotFound>
readonly updateMessage: <T extends SessionV1.Info>(msg: T) => Effect.Effect<T>
Expand Down Expand Up @@ -899,6 +907,10 @@ const layer: Layer.Layer<
return result.reverse()
})

const scanNewestFirst: Interface["scanNewestFirst"] = Effect.fn("Session.scanNewestFirst")((input) =>
MessageV2.scanNewestFirst(input).pipe(Effect.provideService(Database.Service, database)),
)

const removeMessage = Effect.fn("Session.removeMessage")(function* (input: {
sessionID: SessionID
messageID: MessageID
Expand Down Expand Up @@ -971,6 +983,7 @@ const layer: Layer.Layer<
setWorkspace,
diff,
messages,
scanNewestFirst,
children,
remove,
updateMessage,
Expand Down
Loading
Loading