Bläddra i källkod

feat(compact): compaction capability seam — abstract CompactService interface

Adds the @deepseek-ai/dsh-compact interface package: the abstract
CompactService (ctx.compact) with compactIfNeeded / compactRegion, the
compact/* session-event types via SessionEventMap declaration merging, and the
capability-seam RFC. Wires the package into the three root tsconfigs and the
cordis catalog. A backend implementation lands separately.
Hypatia May 3 månader sedan
förälder
incheckning
e45053f0f5

+ 19 - 1
docs/cordis-catalog/events-and-services.md

@@ -279,7 +279,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in
 
 ## Services
 
-The 8 `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.
+The 9 `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.
 
 ### `ctx.agentLoop` — `AgentLoop`
 
@@ -339,6 +339,24 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c
 
 Source: [`packages/bash/bash/src/index.ts:59`](../../packages/bash/bash/src/index.ts)
 
+### `ctx.compact` — `CompactService` (abstract seam)
+
+Abstract compaction service. Subclass implement the two abstract methods, and load the subclass as a plugin — it registers as `ctx.compact` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
+
+Both core methods are abstract: the contract states WHAT compaction does, while the entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation.
+
+Implementations MUST honor:
+
+- **Surface contract**: a successful compaction shadows the compacted surface nodes with a SINGLE replacement node carrying the summary. Because `SurfaceEventType` is a closed union, that node is a `user/message` with `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are log-only (lock + provenance).
+- **Blocking**: no compaction begins while another is in progress for the same session. The recommended mechanism is the log-recorded lock — append `compact/start` before the slow work and `compact/end` after (even on failure) — so the lock is visible to replay and crash recovery.
+
+```ts cordis-catalog
+abstract compactIfNeeded( session: Session, systemPrompt?: string, model?: string, ): Promise<CompactionResult | null>
+abstract compactRegion( session: Session, start: number, end: number, model: string, ): Promise<CompactionResult>
+```
+
+Source: [`packages/compact/compact/src/index.ts:57`](../../packages/compact/compact/src/index.ts)
+
 ### `ctx.llm` — `LlmService`
 
 The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.

+ 3 - 0
docs/module-graph.md

@@ -18,6 +18,8 @@ graph TD
   agent --> brand
   agent --> llm
   agent --> session
+  compact --> llm
+  compact --> session
   llm-replay --> llm
   llm-replay --> session
   session-persistence --> session
@@ -78,6 +80,7 @@ graph TD
 | `session` | `brand`, `llm` |
 | `system-prompt` | `llm` |
 | `agent` | `brand`, `llm`, `session` |
+| `compact` | `llm`, `session` |
 | `llm-replay` | `llm`, `session` |
 | `session-persistence` | `session` |
 | `invariants` | `agent`, `llm`, `session` |

+ 1 - 0
docs/rfc/README.md

@@ -44,6 +44,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
 | [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 |
 | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 |
 | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 |
+| [Compaction as a capability seam (abstract contract + basic backend)](proposed/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 |
 
 ### Simplification
 

+ 57 - 0
docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md

@@ -0,0 +1,57 @@
+# RFC: Compaction as a capability seam (abstract contract + basic backend)
+
+Status: proposed (2026-06-18)
+
+## Context
+
+A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact.
+
+The [session surface](../../implemented/architecture/2026-06-18-session-surface.md) was built as the foundation for exactly this — a linked list over the event log with a `surfaceOp: { op: 'replace', start, end }` operation purpose-built to shadow a range of nodes and insert a replacement, with `sourceEventSeqs` recording provenance so the decision replays deterministically. What remained was the plugin that *decides what to compact and produces the summary*.
+
+Two forces shape the design. First, compaction is **swappable**: token counting can be a char/4 heuristic or a real tokenizer, and summarization can be a model call, a template, or a remote service — these vary independently of *when* and *which range* to compact. Second, a later commit (`ce43c25`) closed `SurfaceEventType` to five event types (`user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`); only those may carry `surfaceOp`. A bespoke `compaction/*` event therefore **cannot** itself appear on the surface — the compiler rejects `surfaceOp` on it and the invariants plugin rejects it at runtime.
+
+## Decision
+
+### Compaction is a capability seam, split interface / implementation
+
+Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently:
+
+1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*.
+2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.generate()`, the surface replacement, the lock, and the `agent/request` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks).
+3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first.
+
+### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation
+
+The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). 
+
+This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. We record the deviation here so a future reader doesn't mistake it for an accident or "fix" it by smuggling `Session` behind an opaque handle.
+
+### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend
+
+An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy (e.g. turn-count instead of token-budget) or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's.
+
+### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary
+
+Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the summary `ContentBlock[]` and whose `sourceEventSeqs` covers the shadowed nodes *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance), never on the surface:
+
+```
+compact/start    → log-only. Acquires the lock.
+[summarize older range via the backend]
+compact/summary  → log-only. Provenance: summary, range, shadowed seqs, token count.
+compact/end      → log-only. Releases the lock.
+user/message     → surfaceOp { op:'replace', start, end }. THE surface mutation.
+                   deriveMessages() renders it as a user-role message.
+```
+
+`deriveMessages()` then yields `[summary_as_user_message, ...retained_nodes]`. An alternative — extending `SurfaceEventType` to admit a `compact/*` type — was rejected: the closed union is a deliberate safety boundary (only message-producing events reach the model), and a summary genuinely *is* user-role context, so reusing `user/message` is honest rather than a workaround.
+
+### Blocking via a log-recorded lock, not a mutex
+
+Compaction must be serialized: no second compaction starts before the first finishes, and no ordinary events interleave the slow summarization. Rather than an in-memory mutex (invisible to replay, lost on crash), the lock **is** the log: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. `compact/start` is appended first (fast, synchronous), the slow model call runs, then `compact/end` is appended — in a `catch` that records the error, so a failed summarization can never wedge the lock. Because the backend runs compaction synchronously inside the `agent/request` waterfall, the loop is single-threaded for that window; the lock additionally gives observability and lets a persistence backend detect an orphaned `compact/start` on reload.
+
+## Consequences
+
+- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the three root tsconfigs. The consumer tier is deferred.
+- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry.
+- **No changes** to `dsh-session`, `dsh-invariants`, or `dsh-agent-loop`: the surface replace op, the surface-metadata runtime guard, and the `agent/request` waterfall all already exist. Compaction is a pure plugin on documented seams.
+- The capability-seams convention gains a second reference beyond bash, and a documented case where "interface depends only on cordis" relaxes to "depends only on interface/vocabulary packages the contract genuinely names." On acceptance, [AGENTS.md](../../../../AGENTS.md) § Conventions and [architecture.md](../../../architecture.md) § "Capability seams" should note this relaxation.

+ 52 - 0
packages/compact/compact/README.md

@@ -0,0 +1,52 @@
+# @deepseek-ai/dsh-compact
+
+The **compaction seam**: an abstract `CompactService` (`ctx.compact`) defining WHAT compaction does — decide when history is too large and summarize an older range into a single surface node — without saying HOW.
+
+This package is the interface tier of the compaction capability, split so each concern evolves (and swaps) independently:
+
+| Package | Role |
+|---|---|
+| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` |
+| `@deepseek-ai/dsh-compact-basic` | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization |
+| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
+
+Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md).
+
+## Service API (`ctx.compact`)
+
+Both methods are **abstract** — the backend owns the entire strategy (token estimation, retention policy, event sequencing, summarization).
+
+| Member | Semantics |
+|---|---|
+| `compactIfNeeded(session, systemPrompt?, model?)` | Estimate the history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. |
+| `compactRegion(session, start, end, model)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start > end`. |
+
+## Surface contract
+
+`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
+
+1. appends `compact/start` (log-only) — acquires the lock,
+2. summarizes the range,
+3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count,
+4. appends `compact/end` (log-only) — releases the lock,
+5. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation**.
+
+`deriveMessages()` then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic.
+
+## Blocking
+
+Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
+
+## Events
+
+The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`:
+
+| Event | Payload | On surface? |
+|---|---|---|
+| `compact/start` | `{ turn }` | no (log-only) |
+| `compact/summary` | `{ summary, compactedRange, compactedEventSeqs, tokenCount }` | no (log-only) |
+| `compact/end` | `{ turn, error? }` | no (log-only) |
+
+## Implementing a backend
+
+Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. See `@deepseek-ai/dsh-compact-basic` for the reference implementation.

+ 32 - 0
packages/compact/compact/package.json

@@ -0,0 +1,32 @@
+{
+  "name": "@deepseek-ai/dsh-compact",
+  "description": "Abstract compaction service seam (ctx.compact) for the DeepSeek Harness",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 102 - 0
packages/compact/compact/src/index.ts

@@ -0,0 +1,102 @@
+/**
+ * The compaction service seam (`ctx.compact`): an abstract service defining
+ * WHAT compaction does — decide when to compact, summarize a range of
+ * conversation history into a single surface node — without saying HOW.
+ *
+ * Implementations subclass {@link CompactService}, implement
+ * {@link CompactService.compactIfNeeded} and {@link CompactService.compactRegion},
+ * and load as a plugin — registering as `ctx.compact` (one implementation per
+ * context). `@deepseek-ai/dsh-compact-basic` (char/4 estimation + token-budget
+ * retention + `ctx.llm.stream()` summarization) is the first. A tokenizer- or
+ * template-based backend swaps in without touching consumers.
+ *
+ * The split follows the capability-seams RFC — interface (this) /
+ * implementation (`dsh-compact-basic`) / consumer (a `/compact` tool, deferred)
+ * — modeled on the bash trio. Unlike `dsh-bash`, this interface necessarily
+ * depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over
+ * a `Session` and its output is the `ContentBlock` vocabulary. That deviation
+ * from the "interface depends only on cordis" guidance is intentional and
+ * recorded in the [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md).
+ *
+ * @module @deepseek-ai/dsh-compact
+ */
+
+import { Context, Service } from 'cordis'
+import type { Session } from '@deepseek-ai/dsh-session'
+import type { CompactionResult } from './types.ts'
+
+export type { CompactionResult } from './types.ts'
+
+declare module 'cordis' {
+  interface Context {
+    compact: CompactService
+  }
+}
+
+/**
+ * Abstract compaction service. Subclass implement the two abstract methods,
+ * and load the subclass as a plugin — it registers as `ctx.compact` (one
+ * implementation per context; loading a second throws, which is cordis'
+ * standard duplicate-service behavior).
+ *
+ * Both core methods are abstract: the contract states WHAT compaction does,
+ * while the entire strategy — token estimation, retention policy, event
+ * sequencing, summarization — is a HOW decision owned by the implementation.
+ *
+ * Implementations MUST honor:
+ * - **Surface contract**: a successful compaction shadows the compacted surface
+ *   nodes with a SINGLE replacement node carrying the summary. Because
+ *   `SurfaceEventType` is a closed union, that node is a `user/message` with
+ *   `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are
+ *   log-only (lock + provenance).
+ * - **Blocking**: no compaction begins while another is in progress for the
+ *   same session. The recommended mechanism is the log-recorded lock — append
+ *   `compact/start` before the slow work and `compact/end` after (even on
+ *   failure) — so the lock is visible to replay and crash recovery.
+ */
+export abstract class CompactService extends Service {
+  constructor(ctx: Context) {
+    super(ctx, 'compact')
+  }
+
+  /**
+   * Check token pressure and compact if the conversation is too large.
+   *
+   * Estimates the current history size (optionally including a system prompt),
+   * and if it exceeds the backend's threshold, compacts an older range via
+   * {@link compactRegion}, keeping recent context intact.
+   *
+   * @param session - the session whose surface may be compacted.
+   * @param systemPrompt - optional system prompt, counted toward the estimate.
+   * @param model - optional summarization model (falls back to backend config).
+   * @returns the compaction result, or `null` if no compaction was needed.
+   */
+  abstract compactIfNeeded(
+    session: Session,
+    systemPrompt?: string,
+    model?: string,
+  ): Promise<CompactionResult | null>
+
+  /**
+   * Forcibly compact a range of surface nodes into a single summary node.
+   *
+   * `start` and `end` are inclusive seqs of surface nodes to shadow; the backend
+   * summarizes their content and appends a replacement surface node. Used by the
+   * (future) `/compact` tool and internally by {@link compactIfNeeded}.
+   *
+   * @param session - the session whose surface is mutated.
+   * @param start - inclusive seq of the first surface node to compact.
+   * @param end - inclusive seq of the last surface node to compact.
+   * @param model - summarization model.
+   * @throws if compaction is already in progress, or if `start`/`end` are not
+   *   valid surface nodes, or if `start > end`.
+   */
+  abstract compactRegion(
+    session: Session,
+    start: number,
+    end: number,
+    model: string,
+  ): Promise<CompactionResult>
+}
+
+export default CompactService

+ 57 - 0
packages/compact/compact/src/types.ts

@@ -0,0 +1,57 @@
+/**
+ * Compaction vocabulary: the result type and the `compact/*` session events.
+ *
+ * Extends {@link SessionEventMap} with `compact/*` event types via declaration
+ * merging. {@link SurfaceEventType} is deliberately NOT extended — `compact/*`
+ * events are log-only markers (lock + provenance); only the five
+ * surface-eligible types can carry `surfaceOp`. The actual surface mutation is
+ * performed by a separate `user/message` event carrying the summary (see the
+ * [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)).
+ *
+ * Configuration lives in the backend, not here: the contract states WHAT
+ * compaction produces, while every tunable (context window, thresholds,
+ * retention budget) is a HOW decision owned by the implementation.
+ *
+ * @module @deepseek-ai/dsh-compact/types
+ */
+
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+
+declare module '@deepseek-ai/dsh-session' {
+  interface SessionEventMap {
+    /** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */
+    'compact/start': { turn: number }
+    /**
+     * Provenance record of a completed summarization — log-only, no surfaceOp.
+     * The summary content is in `data.summary`; the actual surface replacement
+     * is performed by a subsequent `user/message` event that shadows the
+     * compacted range.
+     */
+    'compact/summary': {
+      summary: ContentBlock[]
+      compactedRange: { startSeq: number; endSeq: number }
+      compactedEventSeqs: number[]
+      tokenCount: number
+    }
+    /** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */
+    'compact/end': { turn: number; error?: string }
+  }
+}
+
+/** Result of a successful compaction operation. */
+export interface CompactionResult {
+  /** The seq of the appended `compact/start` event. */
+  startSeq: number
+  /** The seq of the appended `compact/summary` event. */
+  summarySeq: number
+  /** The seq of the appended `compact/end` event. */
+  endSeq: number
+  /** The summary content blocks produced by the backend. */
+  summary: ContentBlock[]
+  /** The seq range that was shadowed [start, end] inclusive. */
+  shadowedRange: { start: number; end: number }
+  /** The seq numbers of all shadowed surface nodes. */
+  shadowedSeqs: number[]
+  /** Estimated token count of the shadowed content. */
+  compactedTokenCount: number
+}

+ 78 - 0
packages/compact/compact/tests/compact.spec.ts

@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { CompactService } from '@deepseek-ai/dsh-compact'
+import type { CompactionResult } from '@deepseek-ai/dsh-compact'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+
+/**
+ * A trivial concrete CompactService implementing the abstract contract. The
+ * interface package owns no algorithm — these tests exercise the seam itself:
+ * service registration, the abstract method shape, and the `compact/*` event
+ * declaration merge.
+ */
+class StubCompactService extends CompactService {
+  override async compactIfNeeded(_session: Session, _systemPrompt?: string, _model?: string): Promise<CompactionResult | null> {
+    return null
+  }
+
+  override async compactRegion(session: Session, start: number, end: number, _model: string): Promise<CompactionResult> {
+    // Minimal stub honoring the lock + log-only event contract.
+    const startEvent = session.append('compact/start', { turn: 0 })
+    const summaryEvent = session.append('compact/summary', {
+      summary: [{ type: 'text', text: 'stub' }],
+      compactedRange: { startSeq: start, endSeq: end },
+      compactedEventSeqs: [],
+      tokenCount: 0,
+    })
+    const endEvent = session.append('compact/end', { turn: 0 })
+    return {
+      startSeq: startEvent.seq,
+      summarySeq: summaryEvent.seq,
+      endSeq: endEvent.seq,
+      summary: [{ type: 'text', text: 'stub' }],
+      shadowedRange: { start, end },
+      shadowedSeqs: [],
+      compactedTokenCount: 0,
+    }
+  }
+}
+
+describe('CompactService seam', () => {
+  it('registers as ctx.compact', () => {
+    const ctx = new Context()
+    void new StubCompactService(ctx)
+    expect(ctx.compact).toBeDefined()
+    expect(ctx.compact).toBeInstanceOf(StubCompactService)
+  })
+
+  it('disposing the fiber unregisters ctx.compact (HMR safety)', async () => {
+    const ctx = new Context()
+    const fiber = await ctx.plugin(StubCompactService)
+    expect(ctx.compact).toBeInstanceOf(StubCompactService)
+    await fiber.dispose()
+    expect(ctx.compact).toBeUndefined()
+  })
+
+  it('exposes the abstract contract methods', async () => {
+    const ctx = new Context()
+    const svc = new StubCompactService(ctx)
+    expect(await svc.compactIfNeeded(new Session(SessionId('s')))).toBeNull()
+  })
+
+  it('compact/* events merge into SessionEventMap and are log-only', async () => {
+    const ctx = new Context()
+    const svc = new StubCompactService(ctx)
+    const session = new Session(SessionId('s'))
+
+    const result = await svc.compactRegion(session, 0, 0, 'm')
+
+    const startEvent = session.events.find(e => e.type === 'compact/start')
+    expect(startEvent).toBeDefined()
+    // Log-only: the compiler rejects surfaceOp on compact/* (not a SurfaceEventType);
+    // verify the runtime value is absent.
+    const raw = startEvent as unknown as { surfaceOp?: unknown }
+    expect(raw.surfaceOp).toBeUndefined()
+    expect(result.summarySeq).toBeGreaterThan(result.startSeq)
+    expect(result.endSeq).toBeGreaterThan(result.summarySeq)
+  })
+})

+ 14 - 0
packages/compact/compact/tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib"
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../../../vendor/cosmokit" },
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../llm/llm" },
+    { "path": "../../core/session" }
+  ]
+}

+ 12 - 0
pnpm-lock.yaml

@@ -118,6 +118,18 @@ importers:
         specifier: ^4.0.0-rc.6
         version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
 
+  packages/compact/compact:
+    devDependencies:
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      cordis:
+        specifier: ^4.0.0-rc.6
+        version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
+
   packages/core/agent:
     devDependencies:
       '@deepseek-ai/dsh-brand':

+ 1 - 0
tsconfig.base.json

@@ -43,6 +43,7 @@
         "./packages/core/*/src",
         "./packages/llm/*/src",
         "./packages/bash/*/src",
+        "./packages/compact/*/src",
         "./packages/session-persistence/*/src",
         "./packages/ui/*/src",
         "./packages/util/*/src",

+ 1 - 0
tsconfig.build.json

@@ -22,6 +22,7 @@
     { "path": "./packages/core/agent-loop" },
     { "path": "./packages/core/agent-core" },
     { "path": "./packages/bash/bash" },
+    { "path": "./packages/compact/compact" },
     { "path": "./packages/llm/llm-deepseek" },
     { "path": "./packages/llm/llm-pi-ai" },
     { "path": "./packages/bash/bash-local" },

+ 1 - 0
tsconfig.typecheck.json

@@ -20,6 +20,7 @@
         "./packages/core/*/src",
         "./packages/llm/*/src",
         "./packages/bash/*/src",
+        "./packages/compact/*/src",
         "./packages/session-persistence/*/src",
         "./packages/ui/*/src",
         "./packages/util/*/src",