Skip to content

Commit 70d2f8c

Browse files
author
Ryan Wyler
committed
feat: collapse/float compaction modes and knowledge pack support
Collapse compaction mode: - Selectively compresses oldest 65% of tokens instead of entire conversation - Merges historical summaries for continuity (configurable: previousSummaries) - Places summary at correct breakpoint position in timeline - TUI toggle cycles standard -> collapse -> float via command palette - insertTriggers=false prevents timestamp collision infinite loops - Preserves real user messages; only deletes synthetic trigger messages Float compaction mode: - Sub-collapses oldest conversation chains before overflow evaluation - Chain detection requires 2+ assistant messages (skips simple Q&A pairs) - bookend algorithm by default; configurable per chainThreshold - Soft-deletes sub-collapsed messages with flux=compacted (not hard-delete) - summary:true flag on sub-collapse result prevents re-trigger loops - detectChains skips already-processed messages (summary:true or flux set) to prevent infinite sub-collapse loop - Token adjustment accounts for sub-collapse savings to prevent re-trigger - Reloads TUI messages after sub-collapse via session.compacted event - Re-parents orphaned chain messages after mid-chain split - splitChainMinThreshold gate prevents processing chains too small for benefit - minFloat threshold: skip sub-collapse until total tokens reach configurable limit - Float pre-check also fires on stop finish (not just before LLM calls) Knowledge pack support: - New KnowledgePack module for injecting knowledge pack messages into sessions - Knowledge pack messages sit at time_created=1,2,... (before compaction breakpoints) and are prepended explicitly at prompt build time (filterCompacted never sees them) - Propagates manually-enabled knowledge packs from parent to subagent sessions - Knowledge pack agent prompt overrides: KP can declare agent.<name>.prompt to replace a built-in agent system prompt for the session (global registry not mutated) - Plugin transform hook receives knowledge pack messages via toModelMessages injection - TUI sidebar: add/remove knowledge packs, mirror to project config on change - Server routes: /knowledge-pack list/add/remove/enable/disable endpoints - Sidebar click race condition fix: refetchActive deferred after server response - Sidebar prompt refocus: restores focus after KP interactions only - Global knowledge packs mirrored to project config on sidebar add/remove - Project config written to .opencode/opencode.json (not config.json) Upstream overflow (413) integration: - Collapse/float modes propagate overflow=true through to the continuation message: when a 413 triggers compaction in collapse or float mode, an overflow explanation message is injected after the collapse completes so the user understands their media attachments were too large - filterCompacted: errored summary messages no longer treated as valid compaction breakpoints (upstream guard: !msg.info.error) - filterCompacted: breakpoint detection no longer requires finish flag on assistant summary (collapse compaction may complete without setting finish) Implementation details: - compaction-extension.ts: self-contained for easy rebasing; all debug logging uses COLLAPSE tag (grep: tail -f ~/.local/share/opencode/log/dev.log | grep COLLAPSE) - detectChains: handles mid-run user interjections in full chain detection - copyUserMessage: inserts duplicate chain anchor when mid-chain compaction splits leave orphaned assistant messages - Identifier.insertCopy / Identifier.insert: generate IDs that sort between existing messages without timestamp collisions - isOverflow routes to CompactionExtension for collapse/float modes (configurable trigger) - Config schema: compaction.method, extractRatio, recentRatio, summaryMaxTokens, previousSummaries, splitChain, splitChainMinThreshold, minFloat, insertTriggers - knowledge.enabled, knowledge.paths, knowledge.packs config fields
1 parent b976f33 commit 70d2f8c

15 files changed

Lines changed: 4134 additions & 29 deletions

File tree

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
103103
})
104104

105105
const sdk = useSDK()
106+
const fullSyncedSessions = new Set<string>()
106107

107108
sdk.event.listen((e) => {
108109
const event = e.details
@@ -194,6 +195,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
194195
break
195196

196197
case "session.deleted": {
198+
if (!store.session) break
197199
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
198200
if (result.found) {
199201
setStore(
@@ -206,6 +208,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
206208
break
207209
}
208210
case "session.updated": {
211+
if (!store.session) break
209212
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
210213
if (result.found) {
211214
setStore("session", result.index, reconcile(event.properties.info))
@@ -225,6 +228,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
225228
break
226229
}
227230

231+
case "session.compacted": {
232+
// Compaction modified messages, invalidate cache and reload
233+
const sessionID = event.properties.sessionID
234+
fullSyncedSessions.delete(sessionID)
235+
sdk.client.session.messages({ sessionID, limit: 100 }).then((messages) => {
236+
setStore(
237+
produce((draft) => {
238+
draft.message[sessionID] = messages.data!.map((x) => x.info)
239+
for (const message of messages.data!) {
240+
draft.part[message.info.id] = message.parts
241+
}
242+
}),
243+
)
244+
})
245+
break
246+
}
247+
228248
case "message.updated": {
229249
const messages = store.message[event.properties.info.sessionID]
230250
if (!messages) {
@@ -266,6 +286,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
266286
}
267287
case "message.removed": {
268288
const messages = store.message[event.properties.sessionID]
289+
if (!messages) break
269290
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
270291
if (result.found) {
271292
setStore(
@@ -319,6 +340,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
319340

320341
case "message.part.removed": {
321342
const parts = store.part[event.properties.messageID]
343+
if (!parts) break
322344
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
323345
if (result.found)
324346
setStore(
@@ -431,7 +453,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
431453
bootstrap()
432454
})
433455

434-
const fullSyncedSessions = new Set<string>()
435456
const result = {
436457
data: store,
437458
set: setStore,

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ export function Session() {
159159
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
160160
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
161161
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
162+
const [compactionMethod, setCompactionMethod] = kv.signal<"standard" | "collapse" | "float">(
163+
"compaction_method",
164+
sync.data.config.compaction?.method ?? "standard",
165+
)
162166

163167
const wide = createMemo(() => dimensions().width > 120)
164168
const sidebarVisible = createMemo(() => {
@@ -465,6 +469,19 @@ export function Session() {
465469
dialog.clear()
466470
},
467471
},
472+
{
473+
title: `Compaction: ${compactionMethod()} -> ${compactionMethod() === "standard" ? "collapse" : compactionMethod() === "collapse" ? "float" : "standard"}`,
474+
value: "session.toggle.compaction_method",
475+
category: "Session",
476+
onSelect: (dialog) => {
477+
setCompactionMethod((prev) => {
478+
if (prev === "standard") return "collapse"
479+
if (prev === "collapse") return "float"
480+
return "standard"
481+
})
482+
dialog.clear()
483+
},
484+
},
468485
{
469486
title: "Unshare session",
470487
value: "session.unshare",

packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useSync } from "@tui/context/sync"
2-
import { createMemo, For, Show, Switch, Match } from "solid-js"
2+
import { createMemo, createResource, createSignal, For, Show, Switch, Match } from "solid-js"
33
import { createStore } from "solid-js/store"
44
import { useTheme } from "../../context/theme"
55
import { Locale } from "@/util/locale"
@@ -11,9 +11,12 @@ import { useKeybind } from "../../context/keybind"
1111
import { useDirectory } from "../../context/directory"
1212
import { useKV } from "../../context/kv"
1313
import { TodoItem } from "../../component/todo-item"
14+
import { useSDK } from "@tui/context/sdk"
15+
import { usePromptRef } from "../../context/prompt"
1416

1517
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
1618
const sync = useSync()
19+
const sdk = useSDK()
1720
const { theme } = useTheme()
1821
const session = createMemo(() => sync.session.get(props.sessionID)!)
1922
const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? [])
@@ -60,6 +63,77 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
6063
}
6164
})
6265

66+
type KPEntry = { id?: string; name: string; displayName: string; version: string; enabled: boolean }
67+
68+
// Whether the KP section is expanded to show all available packs
69+
// Default true: new sessions show the full library so users can add packs immediately
70+
const [kpExpanded, setKpExpanded] = createSignal(true)
71+
72+
// sdk transport helper — routes through Unix socket, not bare fetch
73+
const sdkGet = (url: string, path: Record<string, string>) => (sdk.client as any).client.get({ url, path })
74+
const sdkPost = (url: string, path: Record<string, string>) => (sdk.client as any).client.post({ url, path })
75+
const sdkDelete = (url: string, path: Record<string, string>) => (sdk.client as any).client.delete({ url, path })
76+
77+
// Count of knowledge-pack messages in the sync store — changes whenever the server
78+
// injects or removes a KP (message.updated / message.removed events), driving a refetch.
79+
const kpMessageCount = createMemo(
80+
() => (sync.data.message[props.sessionID] ?? []).filter((m) => (m as any).flux === "knowledge").length,
81+
)
82+
83+
// When collapsed: fetch only active packs (fast, session-scoped)
84+
const [activePacks, { refetch: refetchActive }] = createResource(
85+
() => ({ sessionID: props.sessionID, kpCount: kpMessageCount() }),
86+
async ({ sessionID }) => {
87+
const res = await sdkGet("/session/{sessionID}/knowledge-packs", { sessionID })
88+
if (res.error) return [] as KPEntry[]
89+
return (res.data as { id: string; name: string; displayName: string; version?: string }[]).map(
90+
(p) => ({ ...p, enabled: true }) as KPEntry,
91+
)
92+
},
93+
)
94+
95+
// When expanded: fetch all available packs with enabled flag (reads library dir).
96+
// Depends on activePacks() so it re-fetches whenever active packs change.
97+
const [allPacks, { refetch: refetchAll }] = createResource(
98+
() => (kpExpanded() ? { sessionID: props.sessionID, active: activePacks() } : null),
99+
async ({ sessionID }) => {
100+
const res = await sdkGet("/session/{sessionID}/knowledge-packs/available", { sessionID })
101+
if (res.error) return [] as KPEntry[]
102+
return res.data as KPEntry[]
103+
},
104+
)
105+
106+
const visiblePacks = () => (kpExpanded() ? (allPacks() ?? []) : (activePacks() ?? []))
107+
108+
function togglePack(name: string, version: string, enabled: boolean) {
109+
const sessionID = props.sessionID
110+
// Fire-and-forget the SDK call, then refetch once the server responds.
111+
// The refetch is deferred with setTimeout so the DOM update happens
112+
// outside opentui's mouse event processing — avoiding the race that
113+
// destroys renderables mid-event and corrupts focus state.
114+
const req = enabled
115+
? sdkDelete("/session/{sessionID}/knowledge-packs/{name}/{version}", { sessionID, name, version })
116+
: sdkPost("/session/{sessionID}/knowledge-packs/{name}/{version}", { sessionID, name, version })
117+
req.then(() => setTimeout(() => refetchActive(), 1))
118+
}
119+
120+
const promptRef = usePromptRef()
121+
122+
// After any sidebar mouse interaction opentui clears currentFocusedRenderable
123+
// because sidebar box elements are not focusable renderables. The native
124+
// layer may also do post-processing (hover recheck, mouseUp dispatch) after
125+
// the JS callback returns, so a synchronous focus() can be overwritten.
126+
// Use setTimeout like the dialog system does, and schedule a second check
127+
// to catch focus loss from async re-renders triggered by resource refetch.
128+
function refocusPrompt() {
129+
setTimeout(() => {
130+
promptRef.current?.focus()
131+
}, 1)
132+
setTimeout(() => {
133+
if (!promptRef.current?.focused) promptRef.current?.focus()
134+
}, 50)
135+
}
136+
63137
const directory = useDirectory()
64138
const kv = useKV()
65139

@@ -102,6 +176,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
102176
<text fg={theme.text}>
103177
<b>Context</b>
104178
</text>
179+
<text fg={theme.textMuted}>
180+
compact{" "}
181+
{sync.data.config.compaction?.auto === false
182+
? "disabled"
183+
: kv.get("compaction_method", sync.data.config.compaction?.method ?? "standard")}
184+
</text>
185+
105186
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
106187
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
107188
<text fg={theme.textMuted}>{cost()} spent</text>
@@ -111,7 +192,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
111192
<box
112193
flexDirection="row"
113194
gap={1}
114-
onMouseDown={() => mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)}
195+
onMouseDown={() => {
196+
mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)
197+
refocusPrompt()
198+
}}
115199
>
116200
<Show when={mcpEntries().length > 2}>
117201
<text fg={theme.text}>{expanded.mcp ? "▼" : "▶"}</text>
@@ -167,11 +251,54 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
167251
</Show>
168252
</box>
169253
</Show>
254+
<box>
255+
<box flexDirection="row" gap={1} justifyContent="space-between">
256+
<text fg={theme.text}>
257+
<b>Knowledge Packs</b>
258+
</text>
259+
<text
260+
fg={theme.textMuted}
261+
onMouseDown={() => {
262+
setKpExpanded(!kpExpanded())
263+
if (!kpExpanded()) refetchAll()
264+
refocusPrompt()
265+
}}
266+
>
267+
{kpExpanded() ? "−" : "+"}
268+
</text>
269+
</box>
270+
271+
<For each={visiblePacks()}>
272+
{(kp) => (
273+
<box
274+
flexDirection="row"
275+
gap={1}
276+
onMouseDown={() => {
277+
togglePack(kp.name, kp.version, kp.enabled)
278+
refocusPrompt()
279+
}}
280+
>
281+
<text flexShrink={0} style={{ fg: kp.enabled ? theme.success : theme.textMuted }}>
282+
{kp.enabled ? "•" : "◦"}
283+
</text>
284+
<text fg={kp.enabled ? theme.text : theme.textMuted} wrapMode="word">
285+
{kp.displayName}
286+
<Show when={kp.version}>
287+
<span style={{ fg: theme.textMuted }}> {kp.version}</span>
288+
</Show>
289+
</text>
290+
</box>
291+
)}
292+
</For>
293+
</box>
170294
<box>
171295
<box
172296
flexDirection="row"
173297
gap={1}
174-
onMouseDown={() => sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)}
298+
onMouseDown={() => {
299+
sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)
300+
refocusPrompt()
301+
}}
175302
>
176303
<Show when={sync.data.lsp.length > 2}>
177304
<text fg={theme.text}>{expanded.lsp ? "▼" : "▶"}</text>
@@ -215,7 +342,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
215342
<box
216343
flexDirection="row"
217344
gap={1}
218-
onMouseDown={() => todo().length > 2 && setExpanded("todo", !expanded.todo)}
345+
onMouseDown={() => {
346+
todo().length > 2 && setExpanded("todo", !expanded.todo)
347+
refocusPrompt()
348+
}}
219349
>
220350
<Show when={todo().length > 2}>
221351
<text fg={theme.text}>{expanded.todo ? "▼" : "▶"}</text>
@@ -234,7 +364,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
234364
<box
235365
flexDirection="row"
236366
gap={1}
237-
onMouseDown={() => diff().length > 2 && setExpanded("diff", !expanded.diff)}
367+
onMouseDown={() => {
368+
diff().length > 2 && setExpanded("diff", !expanded.diff)
369+
refocusPrompt()
370+
}}
238371
>
239372
<Show when={diff().length > 2}>
240373
<text fg={theme.text}>{expanded.diff ? "▼" : "▶"}</text>
@@ -288,7 +421,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
288421
<text fg={theme.text}>
289422
<b>Getting started</b>
290423
</text>
291-
<text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
424+
<text
425+
fg={theme.textMuted}
426+
onMouseDown={() => {
427+
kv.set("dismissed_getting_started", true)
428+
refocusPrompt()
429+
}}
430+
>
292431
293432
</text>
294433
</box>

0 commit comments

Comments
 (0)