Skip to content
Open
Prev Previous commit
Next Next commit
feat(opencode-v2): configurable compaction mode (own/passthrough) + d…
…octor surfacing

Add a configurable v2 compaction posture via the plugin's options.compaction:
- own (default): the DB table-of-contents is the summary; the model
  summarization call is skipped. Deterministic and model-cost-free.
- passthrough (alias host): result is left unset so the host model narrates;
  the TOC is still persisted for cross-session resume.

Surface the effective mode in the doctor under "Compaction mode" (v2 only).
Thread the v1/v2 target resolution (detectOpencodeTargetFromConfig,
config-as-ground-truth) into the CLI doctor, getDiagnosticAdapter, and the
MCP-init memo site so the check is reachable in both the CLI and the in-chat
ctx_doctor. A v1-only config never false-positives v2.

v1 behavior is unchanged; the check is gated to the v2 target.
  • Loading branch information
nathanpride committed Sep 22, 2026
commit 2d679786da417edf007a311406de98c5272c08a9
366 changes: 183 additions & 183 deletions cli.bundle.mjs

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion configs/opencode/opencode-v2.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"context-mode"
{
"package": "context-mode",
"options": {
"compaction": "own"
}
}
]
}
11 changes: 10 additions & 1 deletion docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,15 @@ Entries may be a bare string or a `{ "package": "...", "options": { ... } }` obj

**Compaction ownership:** on `ctx.session.hook("compaction")`, context-mode replaces the host's model-generated summary with its own snapshot (`ev.result = { summary }`), skipping the extra model call. The guard only fires when the snapshot is non-empty. The host retains the recent tail (≤ `compaction.keep.tokens`, ~15k) — the old `autoBlock` lever is dropped.

**Compaction mode (configurable):** the posture is set by the plugin's `options.compaction` in the config file:

| Value | Behavior |
|-------|----------|
| `own` (default) | The DB table-of-contents **is** the summary; the model summarization call is **skipped**. Deterministic and model-cost-free. |
| `passthrough` (alias `host`) | `result` is left unset so the **host model narrates** its own summary. The **compacting** session sees the host narrative, **not** the TOC. The TOC is still persisted at compaction so a *different* resumed session claims it via the `context` hook (cross-session resume) — the same delivery `own` mode also provides. |

`passthrough` is the escape hatch for a host or user who wants the model's narrative continuity back instead of a deterministic TOC-as-summary. It is **not** a V1 replica: V1 folded the TOC into the *same* session's compaction summary (`output.context.push`), a lever the V2 `SessionCompaction` type does not expose. So `passthrough` hands the summary to the host and leaves the TOC for cross-session resume only — the compacting session itself does not get the TOC back (the anti-self-injection guard, `session_id != ?`, prevents it). `context-mode doctor` reports the effective mode under **Compaction mode**.

**Routing block + resume pointer:** injected into `ev.system` in the `context` hook. The `context` hook fires only for `kind="primary"` requests, so it never pollutes the compaction-summary path. A quorum guard (`systemHasRoutingInstructions`) keeps the block from being added twice.

**Permission posture (honest — read this):**
Expand All @@ -242,7 +251,7 @@ V2's plugin-tool permission model is **coarser than V1's per-call hooks**. Be aw

When switching targets, the installer writes the correct key and **removes the stale opposite key** (a leftover `plugin` alongside `plugins` would double-register). A pure-V1 config is left byte-identical. `context-mode doctor` warns when a stale opposite-generation key is present.

**Not yet on V2:** usage/token capture (V2's `session.usage.updated` / `step.ended` shape differs from V1's `message.updated` parser) is deferred — it is out of scope for the v2 adapter change.
**Usage / token capture (V2):** shipped. V2 has no `message.updated` event, so capture rides `session.step.ended` — cost, tokens (input/output/reasoning/cache) and session/message IDs are read from the step payload and correlated to the model via `session.step.started`. Reasoning tokens are folded into `output_tokens` (the `AgentUsageCounts` shape has no reasoning field), and V2's native USD cost is recorded verbatim as `native_cost_usd`, bypassing the pricing catalog.

---

Expand Down
2 changes: 1 addition & 1 deletion hooks/security.bundle.mjs

Large diffs are not rendered by default.

206 changes: 103 additions & 103 deletions server.bundle.mjs

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions src/adapters/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,30 @@ export function detectPlatform(clientInfo?: { name: string; version?: string }):
};
}

/**
* Detect the OpenCode v1/v2 adapter target from the user's own config files
* (the `plugins` vs `plugin` key) for read-only diagnosis. This is the
* config-as-ground-truth signal the doctor uses so it reports the compaction
* posture the user actually configured, independent of PATH heuristics.
*
* Returns null for non-opencode platforms or when neither key carries
* context-mode. A v1-only config (`plugin`) returns "v1" — never a false v2.
*/
export async function detectOpencodeTargetFromConfig(
platform: PlatformId,
): Promise<"v1" | "v2" | null> {
if (platform !== "opencode" && platform !== "kilo") return null;
try {
const probe = await getAdapter(platform, "v1");
return (
(probe as { detectTargetFromConfig?: () => "v1" | "v2" | null })
.detectTargetFromConfig?.() ?? null
);
} catch {
return null;
}
}

/**
* Get the adapter instance for a given platform.
* Lazily imports platform-specific adapter modules.
Expand Down
37 changes: 37 additions & 0 deletions src/adapters/opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,21 @@ export class OpenCodeAdapter extends BaseAdapter implements HookAdapter {
});
}

// v2-only: surface the effective compaction posture so it is observable via
// `ctx doctor`. "own" makes the DB TOC the summary and skips the model;
// "passthrough" lets the host model narrate (TOC delivered via resume).
if (this.target === "v2") {
const mode = this.effectiveCompactionMode(settings);
results.push({
check: "Compaction mode",
status: "pass",
message:
mode === "passthrough"
? "passthrough — host model narrates the summary; context-mode TOC delivered via resume"
: "own — context-mode DB table-of-contents is the summary; model summarization skipped",
});
}

if (this.hasLegacyContextModeMcp(settings)) {
results.push({
check: "Legacy MCP registration",
Expand Down Expand Up @@ -647,6 +662,28 @@ export class OpenCodeAdapter extends BaseAdapter implements HookAdapter {
});
}

/**
* Effective v2 compaction posture from the context-mode plugin entry's
* `options.compaction`. Reads the same `{ package, options }` object the v2
* host passes to `ctx.options`. Defaults to "own" when unset or unrecognized;
* "passthrough" (or "host") selects the host-narrates mode.
*/
private effectiveCompactionMode(settings: Record<string, unknown>): "own" | "passthrough" {
const plugins = settings[this.pluginKey];
if (!Array.isArray(plugins)) return "own";
for (const p of plugins) {
if (p && typeof p === "object") {
const pkg = (p as { package?: unknown }).package;
if (typeof pkg === "string" && pkg.includes("context-mode")) {
const raw = (p as { options?: { compaction?: unknown } }).options?.compaction;
const v = String(raw ?? "").trim().toLowerCase();
return v === "passthrough" || v === "host" ? "passthrough" : "own";
}
}
}
return "own";
}

private hasLegacyContextModeMcp(settings: Record<string, unknown>): boolean {
const mcp = settings.mcp;
return !!(
Expand Down
46 changes: 43 additions & 3 deletions src/adapters/opencode/plugin-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,36 @@ function destructivePolicyAllows(name: string): boolean {
return flag === "1" || flag === "true" || flag === "yes";
}

/**
* v2 compaction posture, configurable via the plugin's `options.compaction`.
*
* - "own" (default): the DB table-of-contents becomes the compaction summary and
* the model summarization call is skipped — the strongest form, deterministic
* and model-cost-free.
* - "passthrough": leave the result unset so the host model narrates its own
* summary. The COMPACTING session therefore sees the host's narrative, not the
* TOC. The TOC is still persisted (buildCompactionSnapshot upserts it) so a
* DIFFERENT resumed session claims it via the `context` hook — the same
* cross-session resume delivery own mode also provides. This is the escape hatch
* for a host or user who wants the model's narrative continuity back rather than
* a deterministic TOC-as-summary.
*
* This is deliberately NOT a v1 replica. v1 folded the TOC into the SAME
* session's compaction summary (`output.context.push`); the v2 `SessionCompaction`
* type exposes no such lever — only the `result` override that skips the model.
* So passthrough cannot reproduce v1's same-session TOC-in-summary: it hands the
* summary to the host and leaves the TOC for cross-session resume only. It is
* named for that actual behavior, not "v1", to avoid promising a mechanism the
* v2 API cannot provide.
*/
type CompactionMode = "own" | "passthrough";

/** Resolve `options.compaction` to a {@link CompactionMode}; unrecognized → "own". */
function resolveCompactionMode(raw: unknown): CompactionMode {
const v = String(raw ?? "").trim().toLowerCase();
return v === "passthrough" || v === "host" ? "passthrough" : "own";
}

/**
* Strip the `ctx_` prefix so the effective name (`namespace` + `name`) stays
* `ctx_*`, matching the v1 surface. Registering the bare name under the `ctx`
Expand Down Expand Up @@ -203,6 +233,11 @@ export async function setupV2(ctx: Plugin.Context): Promise<Plugin.Cleanup> {

const core: ContextModeCore = await getCore({ platform, loadScopeDir });

// Compaction posture from the v2 plugin's `options.compaction`: "own" (default)
// makes the TOC the summary and skips the model; "passthrough" lets the host
// model narrate. See {@link CompactionMode}.
const compactionMode = resolveCompactionMode(ctx.options?.compaction);

// Per-session workspace directory: resolved from the calling session's own
// location (not just the plugin-load-scope dir) and cached so repeated calls
// for one session reuse the resolution. Falls back to the load-scope dir.
Expand Down Expand Up @@ -341,13 +376,18 @@ export async function setupV2(ctx: Plugin.Context): Promise<Plugin.Cleanup> {
disposers.push(() => contextReg.dispose());

// ── 6. Compaction — DB table-of-contents as the summary ─────────────
// Supplying `result` skips the model summarization call. An empty TOC leaves
// the result unset so the host performs its normal compaction.
// "own" (default): supplying `result` skips the model summarization call.
// "passthrough": `result` is left unset so the host model narrates; the TOC
// still reaches the model via the resume path (buildCompactionSnapshot upserts
// it, the `context` hook claims it next turn). An empty TOC leaves the result
// unset in either mode so the host performs its normal compaction.
const compactionReg = await ctx.session.hook("compaction", async (ev) => {
const project = await resolveDir(ev.sessionID);
const res = core.buildCompactionSnapshot(ev.sessionID, project);
if (!res || !res.snapshot || res.snapshot.trim().length === 0) return;
ev.result = { summary: res.snapshot };
if (compactionMode === "own") {
ev.result = { summary: res.snapshot };
}
});
disposers.push(() => compactionReg.dispose());

Expand Down
10 changes: 7 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ function browserOpenArgv(
}

// ── Adapter imports ──────────────────────────────────────
import { detectPlatform, getAdapter } from "./adapters/detect.js";
import { detectPlatform, getAdapter, detectOpencodeTargetFromConfig } from "./adapters/detect.js";
import { isInProcessPluginPlatform } from "./adapters/types.js";

/* -------------------------------------------------------
Expand Down Expand Up @@ -665,12 +665,16 @@ async function doctor(): Promise<number> {

// Detect platform
const detection = detectPlatform();
const adapter = await getAdapter(detection.platform);
// Resolve the OpenCode v1/v2 target from the user's own config so the doctor
// reports the compaction posture they actually configured (v2-only check).
const detectOpencodeTargetFromConfig(detection.platform)) ?? "v1";
const adapter = await getAdapter(detection.platform, target);

p.intro(color.bgMagenta(color.white(" context-mode doctor ")));
p.log.info(
`Platform: ${color.cyan(adapter.name)}` +
color.dim(` (${detection.confidence} confidence — ${detection.reason})`),
color.dim(` (${detection.confidence} confidence — ${detection.reason})`) +
(target === "v2" ? color.cyan(" · v2 target") : color.dim(" · v1 target")),
);

let criticalFails = 0;
Expand Down
14 changes: 10 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,9 +541,12 @@ let _detectedAdapter: HookAdapter | null = null;
async function getDiagnosticAdapter(): Promise<HookAdapter | null> {
if (_detectedAdapter) return _detectedAdapter;
try {
const { getAdapter } = await import("./adapters/detect.js");
const { getAdapter, detectOpencodeTargetFromConfig } = await import("./adapters/detect.js");
const signal = detectPlatform();
return await getAdapter(signal.platform);
// Resolve the OpenCode v1/v2 target from the user's config so the in-chat
// ctx_doctor reports the compaction posture they actually configured.
const detectOpencodeTargetFromConfig(signal.platform)) ?? "v1";
return await getAdapter(signal.platform, target);
} catch {
return null;
}
Expand Down Expand Up @@ -5379,10 +5382,13 @@ async function main() {

// Detect platform adapter — stored for platform-aware session paths
try {
const { detectPlatform, getAdapter } = await import("./adapters/detect.js");
const { detectPlatform, getAdapter, detectOpencodeTargetFromConfig } = await import("./adapters/detect.js");
const clientInfo = server.server.getClientVersion();
const signal = detectPlatform(clientInfo ?? undefined);
_detectedAdapter = await getAdapter(signal.platform);
// Resolve the OpenCode v1/v2 target from the user's config so the memoized
// diagnostic adapter carries the correct plugin key + compaction posture.
const detectOpencodeTargetFromConfig(signal.platform)) ?? "v1";
_detectedAdapter = await getAdapter(signal.platform, target);
if (clientInfo) {
console.error(`MCP client: ${clientInfo.name} v${clientInfo.version} → ${signal.platform}`);
}
Expand Down
Loading