Skip to content

Commit dbc6d62

Browse files
Merge pull request #17 from morphllm/fix/issue-13-compact-event
Fix Morph handling of OpenCode compaction events
2 parents 2ac489f + fe60b30 commit dbc6d62

3 files changed

Lines changed: 265 additions & 22 deletions

File tree

README.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,20 @@ You should see `morph_edit`, `warpgrep_codebase_search`, and `warpgrep_github_se
6060

6161
## Compaction
6262

63-
Context compression via the Morph Compact API. Runs automatically before each LLM call when the conversation exceeds a token threshold.
63+
Context compression via the Morph Compact API. In current OpenCode 1.14.x releases, only OpenCode native compaction writes the persisted summary message that future turns and the sidebar use. This plugin handles that path by pre-compressing the selected history with Morph before OpenCode's native compaction model writes its summary.
6464

6565
### How it works
6666

67-
1. Before each LLM call, the plugin estimates the total characters in the conversation
68-
2. If the estimate exceeds the threshold, older messages are compressed via the Morph Compact API (~250ms)
69-
3. The compressed result is cached ("frozen") and reused on subsequent calls for prompt cache stability
70-
4. Only the most recent user message is kept uncompacted
67+
1. When OpenCode native compaction starts, the plugin adds Morph-aware instructions to the compaction prompt
68+
2. The following `experimental.chat.messages.transform` hook receives the history OpenCode selected for compaction
69+
3. Morph compresses that selected history to one summary message before OpenCode sends it to the compaction model
70+
4. OpenCode then persists its normal compaction summary and emits `session.compacted`
7171

72-
The LLM receives compressed history + your latest prompt. The "Context: X tokens" number in the sidebar reflects the actual tokens sent (post-compaction).
72+
The Morph toast means the compaction input was compressed for OpenCode. Seeing OpenCode native compaction immediately after the toast is expected; that is the mechanism that persists the summary. The "Context: X tokens" number in the sidebar is based on OpenCode's stored assistant token usage, so it updates after OpenCode finishes compaction and/or after the next assistant response, not at the instant the Morph toast appears.
7373

7474
### Configuring the compaction threshold
7575

76-
By default, compaction triggers at **70% of the model's context window**. You can override this with a fixed token limit:
76+
For non-native transform calls, the plugin uses a default threshold of **70% of the model's context window**. With a 1M token model, that is roughly 700k estimated tokens. You can override this with a fixed token limit:
7777

7878
```bash
7979
# Compact when conversation exceeds 20,000 tokens
@@ -97,13 +97,19 @@ grep "service=morph" ~/.local/share/opencode/log/*.log | grep -i compact
9797
When compaction fires, you'll see entries like:
9898

9999
```
100-
INFO service=morph First compaction: 2 messages (30137 chars), keeping 1 recent. Threshold crossed: 30178 >= 15000
101-
INFO service=morph Compact: 2 messages -> 2 frozen (15142 chars). Messages: 3 -> 3. Ratio: 45% kept (244ms)
100+
INFO service=morph OpenCode native compaction triggered; Morph will pre-compress selected history and OpenCode will persist the summary.
101+
INFO service=morph Native compaction: compressing 42 selected messages (210137 chars) before OpenCode writes its persisted summary.
102+
INFO service=morph Native compaction: Morph compressed 42 messages -> 1 summary (15142 chars). Ratio: 20% kept (244ms)
103+
INFO service=morph OpenCode native compaction completed; cleared Morph transient compaction state.
102104
```
103105

104-
You'll also see a toast notification in the OpenCode UI when compaction triggers.
106+
You'll also see a toast notification in the OpenCode UI:
105107

106-
On subsequent LLM calls (before re-compaction is needed), you'll see:
108+
```
109+
Prepared OpenCode compaction with Morph (20% kept) | 244ms
110+
```
111+
112+
If OpenCode native compaction is not involved and a future OpenCode version calls `experimental.chat.messages.transform` before normal LLM turns, the plugin still has the older proactive path. In that path, subsequent LLM calls can show:
107113

108114
```
109115
INFO service=morph Under threshold - reusing frozen block. Messages: 5 -> 5

index.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ describe("packaged tool-selection instructions", () => {
114114
expect(content).toContain("warpgrep_github_search");
115115
expect(content).toContain("MORPH_API_KEY");
116116
expect(content).toContain("MORPH_COMPACT_TOKEN_LIMIT");
117+
expect(content).toContain("OpenCode native compaction");
118+
expect(content).toContain("sidebar");
117119
expect(content).toContain("opencode.json");
118120
});
119121
});
@@ -963,6 +965,103 @@ describe("plugin runtime hooks", () => {
963965
);
964966
expect(combined).toContain("Use write for brand new files.");
965967
});
968+
969+
test("native session compaction is pre-compressed by Morph while preserving OpenCode prompt anchoring", async () => {
970+
const originalCompact = CompactClient.prototype.compact;
971+
const logs: any[] = [];
972+
const toasts: any[] = [];
973+
let capturedCompactArgs: any;
974+
975+
CompactClient.prototype.compact = async function (args: any) {
976+
capturedCompactArgs = args;
977+
return {
978+
output: "condensed task summary",
979+
messages: [{ role: "user", content: "condensed task summary" }],
980+
usage: {
981+
compression_ratio: 0.2,
982+
processing_time_ms: 42,
983+
input_tokens: 100,
984+
output_tokens: 20,
985+
},
986+
} as any;
987+
};
988+
989+
try {
990+
const { default: MorphPlugin } = await importPluginWithEnv({
991+
MORPH_API_KEY: "sk-test-key",
992+
MORPH_COMPACT_TOKEN_LIMIT: undefined,
993+
});
994+
995+
const input = makePluginInput("/tmp/morph-plugin") as any;
996+
input.client = {
997+
app: {
998+
log: async ({ body }: any) => {
999+
logs.push(body);
1000+
},
1001+
},
1002+
tui: {
1003+
showToast: async ({ body }: any) => {
1004+
toasts.push(body);
1005+
},
1006+
},
1007+
};
1008+
1009+
const hooks = await MorphPlugin(input);
1010+
const compactingOutput: { context: string[]; prompt?: string } = {
1011+
context: [],
1012+
};
1013+
1014+
await hooks["experimental.session.compacting"]?.(
1015+
{ sessionID: "session-test" },
1016+
compactingOutput,
1017+
);
1018+
1019+
expect(compactingOutput.prompt).toBeUndefined();
1020+
expect(compactingOutput.context).toHaveLength(1);
1021+
expect(compactingOutput.context[0]).toContain(
1022+
"Morph compact plugin is active",
1023+
);
1024+
expect(compactingOutput.context[0]).toContain("Morph-compressed history");
1025+
1026+
const output = {
1027+
messages: [
1028+
makeTextMsg("msg-1", "user", "please refactor auth"),
1029+
makeTextMsg(
1030+
"msg-2",
1031+
"assistant",
1032+
"read src/auth.ts and found token storage",
1033+
),
1034+
],
1035+
};
1036+
1037+
await hooks["experimental.chat.messages.transform"]?.({}, output as any);
1038+
1039+
expect(capturedCompactArgs.messages).toEqual([
1040+
{ role: "user", content: "please refactor auth" },
1041+
{
1042+
role: "assistant",
1043+
content: "read src/auth.ts and found token storage",
1044+
},
1045+
]);
1046+
expect(capturedCompactArgs.preserveRecent).toBe(0);
1047+
expect(output.messages).toHaveLength(1);
1048+
expect(output.messages[0]!.parts[0]!.type).toBe("text");
1049+
expect((output.messages[0]!.parts[0] as any).text).toContain(
1050+
"Morph-compressed conversation history",
1051+
);
1052+
expect((output.messages[0]!.parts[0] as any).text).toContain(
1053+
"condensed task summary",
1054+
);
1055+
expect(toasts[0]!.message).toContain(
1056+
"Prepared OpenCode compaction with Morph",
1057+
);
1058+
expect(logs.some((entry) => entry.message.includes("persisted summary"))).toBe(
1059+
true,
1060+
);
1061+
} finally {
1062+
CompactClient.prototype.compact = originalCompact;
1063+
}
1064+
});
9661065
});
9671066

9681067
describe("ToolContext path resolution", () => {

index.ts

Lines changed: 149 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,24 @@ let compactionState: {
127127
frozenChars: number;
128128
} | null = null;
129129

130+
const NATIVE_COMPACTION_ARM_TTL_MS = 30_000;
131+
const nativeCompactionArms: number[] = [];
132+
133+
function armNativeCompaction() {
134+
nativeCompactionArms.push(Date.now());
135+
}
136+
137+
function consumeNativeCompactionArm(): boolean {
138+
const now = Date.now();
139+
140+
while (nativeCompactionArms.length > 0) {
141+
const armedAt = nativeCompactionArms.shift()!;
142+
if (now - armedAt <= NATIVE_COMPACTION_ARM_TTL_MS) return true;
143+
}
144+
145+
return false;
146+
}
147+
130148
/**
131149
* Normalize code_edit input from LLM tool calls.
132150
*
@@ -215,6 +233,47 @@ function estimateTotalChars(
215233
return total;
216234
}
217235

236+
function formatCompressionPercent(result: CompactResult): number {
237+
return Math.round(result.usage.compression_ratio * 100);
238+
}
239+
240+
function compactResultText(result: CompactResult): string {
241+
const output = result.output?.trim();
242+
if (output) return output;
243+
244+
return result.messages
245+
.map((message) => `[${message.role}] ${message.content}`)
246+
.join("\n\n")
247+
.trim();
248+
}
249+
250+
function buildCompactedSummaryMessages(
251+
originalMessages: { info: Message; parts: Part[] }[],
252+
result: CompactResult,
253+
): { info: Message; parts: Part[] }[] {
254+
if (originalMessages.length === 0) return [];
255+
256+
const template = originalMessages[0]!;
257+
const last = originalMessages[originalMessages.length - 1]!;
258+
const summary = compactResultText(result);
259+
260+
if (!summary) return [];
261+
262+
return [
263+
{
264+
info: { ...template.info } as Message,
265+
parts: [
266+
{
267+
id: `morph-compact-summary-${template.info.id}-${last.info.id}`,
268+
sessionID: template.info.sessionID,
269+
messageID: template.info.id,
270+
type: "text" as const,
271+
text: `Morph-compressed conversation history:\n\n${summary}`,
272+
} as TextPart,
273+
],
274+
},
275+
];
276+
}
218277

219278
function resolveSessionFilepath(
220279
targetFilepath: string,
@@ -1225,6 +1284,17 @@ Get your API key at: https://morphllm.com/dashboard/api-keys`;
12251284
};
12261285

12271286
if (MORPH_COMPACT_ENABLED) {
1287+
hooks.event = async ({ event }: any) => {
1288+
if (event.type !== "session.compacted") return;
1289+
1290+
compactionState = null;
1291+
nativeCompactionArms.length = 0;
1292+
await log(
1293+
"info",
1294+
"OpenCode native compaction completed; cleared Morph transient compaction state.",
1295+
);
1296+
};
1297+
12281298
// Capture model context window from chat.params (fires every LLM call)
12291299
hooks["chat.params"] = async (input: any) => {
12301300
//chat.params CALLED: model=${input.model?.id}, context=${input.model?.limit?.context}`);
@@ -1233,14 +1303,68 @@ Get your API key at: https://morphllm.com/dashboard/api-keys`;
12331303
}
12341304
};
12351305

1236-
// Compaction: compress older messages via Morph, then FREEZE the result.
1237-
// The frozen block is reused byte-for-byte on every subsequent call,
1238-
// preserving the LLM provider's prompt prefix cache.
1239-
// Re-compaction only fires when the threshold is crossed again.
1306+
// Compaction: current OpenCode calls this during native session compaction.
1307+
// When armed by experimental.session.compacting, Morph reduces the selected
1308+
// history to a single summary before OpenCode writes its persisted summary.
1309+
// The fallback path below preserves the older proactive/frozen behavior for
1310+
// OpenCode versions that call this hook before normal LLM turns.
12401311
hooks["experimental.chat.messages.transform"] = async (_input: any, output: any) => {
12411312
if (!MORPH_API_KEY) return;
12421313

12431314
const messages = output.messages;
1315+
const nativeCompaction = consumeNativeCompactionArm();
1316+
1317+
if (nativeCompaction) {
1318+
const compactInput = messagesToCompactInput(messages);
1319+
if (compactInput.length === 0) {
1320+
await log(
1321+
"debug",
1322+
"Native compaction: selected history had no compactable text; using OpenCode compaction unchanged.",
1323+
);
1324+
return;
1325+
}
1326+
1327+
await log(
1328+
"info",
1329+
`Native compaction: compressing ${messages.length} selected messages (${estimateTotalChars(messages)} chars) before OpenCode writes its persisted summary.`,
1330+
);
1331+
1332+
try {
1333+
const result = await compactClient!.compact({
1334+
messages: compactInput,
1335+
compressionRatio: COMPACT_RATIO,
1336+
preserveRecent: 0,
1337+
});
1338+
1339+
const compacted = buildCompactedSummaryMessages(messages, result);
1340+
if (compacted.length === 0) {
1341+
await log(
1342+
"warn",
1343+
"Native compaction: Morph returned an empty summary; using OpenCode compaction unchanged.",
1344+
);
1345+
return;
1346+
}
1347+
1348+
const compactedChars = estimateTotalChars(compacted);
1349+
output.messages = compacted;
1350+
compactionState = null;
1351+
1352+
await log(
1353+
"info",
1354+
`Native compaction: Morph compressed ${messages.length} messages -> ${compacted.length} summary (${compactedChars} chars). Ratio: ${formatCompressionPercent(result)}% kept (${result.usage.processing_time_ms}ms)`,
1355+
);
1356+
await showToast(
1357+
"success",
1358+
`Prepared OpenCode compaction with Morph (${formatCompressionPercent(result)}% kept) | ${result.usage.processing_time_ms}ms`,
1359+
);
1360+
} catch (err) {
1361+
await log(
1362+
"warn",
1363+
`Native compaction: Morph compact failed: ${(err as Error).message}. Using OpenCode compaction unchanged.`,
1364+
);
1365+
}
1366+
return;
1367+
}
12441368

12451369
// Approximate char threshold — fixed token limit takes precedence
12461370
const charThreshold = COMPACT_TOKEN_LIMIT
@@ -1317,11 +1441,11 @@ Get your API key at: https://morphllm.com/dashboard/api-keys`;
13171441

13181442
await log(
13191443
"info",
1320-
`Compact (re): ${toCompact.length} messages ${frozen.length} frozen (${frozenChars} chars). Messages: ${beforeLen} ${output.messages.length}. Ratio: ${Math.round(result.usage.compression_ratio * 100)}% kept (${result.usage.processing_time_ms}ms)`,
1444+
`Compact (re): ${toCompact.length} messages -> ${frozen.length} frozen (${frozenChars} chars). Messages: ${beforeLen} -> ${output.messages.length}. Ratio: ${formatCompressionPercent(result)}% kept (${result.usage.processing_time_ms}ms)`,
13211445
);
13221446
await showToast(
13231447
"success",
1324-
`${toCompact.length} messages re-compacted (${Math.round(result.usage.compression_ratio * 100)}% kept) | ${result.usage.processing_time_ms}ms`,
1448+
`${toCompact.length} messages re-compacted (${formatCompressionPercent(result)}% kept) | ${result.usage.processing_time_ms}ms`,
13251449
);
13261450
} catch (err) {
13271451
// On failure, use stale frozen block + uncompacted as best-effort
@@ -1378,11 +1502,11 @@ Get your API key at: https://morphllm.com/dashboard/api-keys`;
13781502
//Compact done: ${toCompact.length} msgs → ${frozen.length} frozen (${frozenChars} chars). Messages: ${beforeLen} → ${output.messages.length}`);
13791503
await log(
13801504
"info",
1381-
`Compact: ${toCompact.length} messages ${frozen.length} frozen (${frozenChars} chars). Messages: ${beforeLen} ${output.messages.length}. Ratio: ${Math.round(result.usage.compression_ratio * 100)}% kept (${result.usage.processing_time_ms}ms)`,
1505+
`Compact: ${toCompact.length} messages -> ${frozen.length} frozen (${frozenChars} chars). Messages: ${beforeLen} -> ${output.messages.length}. Ratio: ${formatCompressionPercent(result)}% kept (${result.usage.processing_time_ms}ms)`,
13821506
);
13831507
await showToast(
13841508
"success",
1385-
`${toCompact.length} messages compacted (${Math.round(result.usage.compression_ratio * 100)}% kept) | ${result.usage.processing_time_ms}ms`,
1509+
`${toCompact.length} messages compacted (${formatCompressionPercent(result)}% kept) | ${result.usage.processing_time_ms}ms`,
13861510
);
13871511
} catch (err) {
13881512
//Compact FAILED: ${(err as Error).message}\n${(err as Error).stack}`);
@@ -1393,11 +1517,25 @@ Get your API key at: https://morphllm.com/dashboard/api-keys`;
13931517
}
13941518
};
13951519

1396-
// When OpenCode's native compaction triggers, log it
1520+
// OpenCode's native compaction is the only path that writes a persisted
1521+
// summary message in current OpenCode versions. Morph compresses the
1522+
// selected history before that summary model call.
13971523
hooks["experimental.session.compacting"] = async (_input: any, output: any) => {
1398-
await log("debug", "OpenCode native compaction triggered");
1524+
if (!MORPH_API_KEY) {
1525+
output.context.push(
1526+
"Note: Morph compact plugin is installed, but MORPH_API_KEY is not configured.",
1527+
);
1528+
return;
1529+
}
1530+
1531+
armNativeCompaction();
13991532
output.context.push(
1400-
"Note: Morph compact plugin is active. Older messages may already be compressed.",
1533+
"Morph compact plugin is active. The selected conversation history will be pre-compressed before this native compaction summary is generated. Treat any Morph-compressed history as authoritative and preserve concrete facts, file paths, commands, errors, constraints, decisions, and remaining work.",
1534+
);
1535+
1536+
await log(
1537+
"info",
1538+
"OpenCode native compaction triggered; Morph will pre-compress selected history and OpenCode will persist the summary.",
14011539
);
14021540
};
14031541
}

0 commit comments

Comments
 (0)