feat(compact-basic): baseline compaction backend (squashed from compact-basic)

Collapses the per-round review churn of the prior compact-basic branch into a
single clean baseline on top of compact-interface, so the upcoming retention
refactor lands as fresh, well-scoped commits rather than stacking on a history
of fixes that are being superseded.
This commit is contained in:
Hypatia May
2026-06-25 17:30:42 +08:00
parent 4c1b7191f3
commit aa9afcefc7
23 changed files with 2628 additions and 27 deletions

View File

@@ -192,7 +192,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl
| `/loop` | on `agent/turn-end`, `send()` the next iteration; or force-continue |
| Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) |
| Queued + steering messages | core `Agent.send()` / `Agent.steer()` |
| Context compaction (auto + manual) | the `ctx.compact` seam ([dsh-compact](../packages/compact/compact)): a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure at turn boundaries, manual = a `/compact` tool. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) |
| Context compaction (auto + manual) | the `dsh-compact` seam (`ctx.compact`) + a backend (`dsh-compact-basic`) wrapping `agent/request`: a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure before each model call, manual = a (deferred) `/compact` tool invoking the same `ctx.compact` routine. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) |
| System prompt configurability | `ctx.systemPrompt.section()` with ordering |
| AGENTS.md (root) | a section provider reading the file |
| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener |
@@ -220,6 +220,6 @@ Code skeletons for the three plugin shapes (tool, hook/permission-gate, UI) and
Tracked here deliberately — each is designed-for but not implemented:
- **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events.
- **Compaction implementation** (auto thresholds, summarization prompts) on the `agent/request` seam, with its session-event types added by declaration merging.
- **Compaction** — the `dsh-compact` seam (`ctx.compact`) and the `dsh-compact-basic` backend exist (auto thresholds, summarization on the `agent/request` seam, `compact/*` session events via declaration merging). The model-facing `/compact` consumer tool is still deferred. See [the compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md).
- **Parallel tool execution** (concurrency-safety hints on ToolDefinition).
- **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking.

View File

@@ -207,7 +207,7 @@ A session was created in the store.
'session/created'(session: Session): void
```
Source: [`packages/core/session/src/index.ts:33`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:34`](../../packages/core/session/src/index.ts)
#### `session/event` — emit
@@ -219,7 +219,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per-
Types: [SessionEvent](../core-data-structures/core.md)
Source: [`packages/core/session/src/index.ts:39`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:40`](../../packages/core/session/src/index.ts)
#### `session/flush` — parallel
@@ -229,7 +229,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus
'session/flush'(session: Session): Promise<void> | void
```
Source: [`packages/core/session/src/index.ts:48`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:49`](../../packages/core/session/src/index.ts)
### `subagent/*`
@@ -430,7 +430,7 @@ get(id: SessionId): Session | undefined
list(): Session[]
```
Source: [`packages/core/session/src/index.ts:321`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:322`](../../packages/core/session/src/index.ts)
### `ctx.subagents` — `SubagentService`

View File

@@ -1,6 +1,6 @@
# Compaction
The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as `dsh-compact-basic`, deferred), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)).
The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)).
Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts)
@@ -11,7 +11,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de
| Event | Payload | Role |
|---|---|---|
| `compact/start` | `{ turn }` | acquires the log-recorded lock |
| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | provenance: the summary blocks, the shadowed seq range, and the estimated token count |
| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | provenance: the summary blocks, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, and the estimated token count |
| `compact/end` | `{ turn, error? }` | releases the lock (`error` set when summarization threw) |
The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished.
@@ -32,9 +32,16 @@ interface CompactionResult {
endSeq: number
/** The summary content blocks produced by the backend. */
summary: ContentBlock[]
/** The seq range that was shadowed [start, end] inclusive. */
/**
* The surface-boundary pair that was shadowed: the seqs of the first
* (`start`) and last (`end`) surface nodes of the replaced range. A
* surface-POSITION span, not a numeric seq interval — after a prior replace
* lands a fresh high-seq summary node at an older range's position, `start`
* can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the
* authoritative set of shadowed nodes, in surface order.
*/
shadowedRange: { start: number; end: number }
/** The seq numbers of all shadowed surface nodes. */
/** The seqs of all shadowed surface nodes, in surface order. */
shadowedSeqs: number[]
/** Estimated token count of the shadowed content. */
shadowedTokenCount: number

View File

@@ -23,6 +23,10 @@ graph TD
llm-replay --> llm
llm-replay --> session
session-persistence --> session
compact-basic --> agent
compact-basic --> compact
compact-basic --> llm
compact-basic --> session
invariants --> agent
invariants --> llm
invariants --> session
@@ -106,6 +110,7 @@ graph TD
| `compact` | `llm`, `session` |
| `llm-replay` | `llm`, `session` |
| `session-persistence` | `session` |
| `compact-basic` | `agent`, `compact`, `llm`, `session` |
| `invariants` | `agent`, `llm`, `session` |
| `session-persistence-jsonl` | `session`, `session-persistence` |
| `session-persistence-sqlite` | `session`, `session-persistence` |

View File

@@ -11,7 +11,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
@@ -29,7 +29,8 @@ dsh-bash ← dsh-brand (abstract executor seam; b
dsh-session ← dsh-llm, dsh-brand
dsh-system-prompt ← dsh-llm
dsh-agent ← dsh-llm, dsh-session, dsh-brand
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred)
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred)
dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend)
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
@@ -68,6 +69,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
| `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` |

View File

@@ -1,11 +1,11 @@
# compact/ — compaction capability family
A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. Only the interface tier exists today; the backend and consumer are deferred. All **product** packages.
A three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
| `compact-basic/` (deferred) | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
The interface lives at `compact/compact/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. 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). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. 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). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.

View File

@@ -0,0 +1,45 @@
# @deepseek-ai/dsh-compact-basic
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and `ctx.llm.stream()` summarization.
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) for the design.
## What it owns
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length).
- **Retention policy** — `compactIfNeeded()` ALWAYS retains the in-flight turn's surface nodes verbatim (its initiating request and any mid-turn tool results — the exact input/observation the model is acting on, even if they exceed the budget), then walks the OLDER (closed-turn) nodes tail→head, summing per-node token estimates, and compacts everything older than the first node that overflows the `retainTokens` budget. The cutoff is snapped to a step boundary so the compacted region never splits a step's `assistant/message` tool-calls from their `tool/result`s (the budget is a soft target): it prefers snapping FORWARD to the next clean boundary, and falls back to snapping BACKWARD when the forward snap would reach the protected in-flight turn. If no step-aligned cutoff exists in the older range (e.g. its only content is an open tail step), it declines (returns `null`) and retries once an older step closes. `compactRegion()` enforces step-alignment strictly, throwing on a boundary that would split a step. Token-based (not turn-count) retention keeps more short turns and compacts tool-heavy turns sooner.
- **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
- **Surface mutation** — `compactRegion()` appends the `compact/start``compact/summary``compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
- **Auto-compaction** — an `agent/request` waterfall listener delegates to `compactIfNeeded()` before every model call (every step, not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts) and re-derives messages after compacting; the listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`).
`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing.
## Config (`BasicCompactConfig`)
| Key | Default | Meaning |
|---|---|---|
| `contextWindow` | `128000` | Context window size in tokens. |
| `thresholdRatio` | `0.8` | Compact when estimated usage exceeds this fraction of the window. |
| `retainTokens` | `20480` | Tokens of recent context to keep intact. |
| `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). |
| `summarizationMaxTokens` | `2048` | Max tokens for the summary response. |
| `auto` | `true` | Register the `agent/request` auto-compaction listener. Set `false` for manual-only. |
## Usage
```ts
import type { Context } from 'cordis'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
export const name = 'compact-basic'
export const inject = ['llm']
export function apply(ctx: Context): void {
ctx.plugin(BasicCompactService, { contextWindow: 128000, retainTokens: 20480 })
}
```
Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.

View File

@@ -0,0 +1,39 @@
{
"name": "@deepseek-ai/dsh-compact-basic",
"description": "Basic compaction backend (char/4 token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,753 @@
/**
* `BasicCompactService`: the first implementation of the
* `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
*
* - **Token estimation** — char/4 heuristic with per-block structural overhead.
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
* to a token budget, compact everything older. The cutoff is snapped forward
* to the next step boundary so a compacted region never splits a step's
* tool-call/result pair (an open tail step is never crossed — compaction
* declines and retries once it closes).
* - **Summarization** — `ctx.llm.stream()` assembled via `BlockAssembler`
* (the single model-call surface; same path the loop uses) with a fixed
* condense-the-history system prompt.
* - **Surface mutation** — a single `user/message` replace node carries the
* summary; `compact/*` events are log-only lock + provenance records.
* - **Auto-compaction** — an `agent/request` waterfall listener delegates to
* {@link BasicCompactService.compactIfNeeded} before EVERY model call (every
* step, so a tool-heavy turn that grows the surface mid-turn still compacts);
* it owns the sole token-pressure check.
*
* A different backend (real tokenizer, template summarizer, turn-count
* retention) either subclasses this and overrides the {@link
* BasicCompactService.estimateContentTokens} / {@link
* BasicCompactService.summarize} hooks, or implements the abstract
* {@link CompactService} from scratch.
*
* @module @deepseek-ai/dsh-compact-basic
*/
import { Context } from 'cordis'
import { CompactService } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
import { isStepAlignedStart, isStepAlignedEnd } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
import { resolveConfig } from './types.ts'
export type { BasicCompactConfig, ResolvedConfig } from './types.ts'
export { DEFAULTS, resolveConfig } from './types.ts'
/** Per-block structural overhead for JSON framing / type tag. */
const BLOCK_OVERHEAD = 4
/** Heuristic token count for an image block (~85 tokens for low-res URL). */
const IMAGE_TOKEN_COST = 85
/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
const ROLE_OVERHEAD = 4
/** Tags wrapping the structured summary inside the landed checkpoint node. */
const SUMMARY_OPEN_TAG = '<compacted-summary>'
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
/**
* The summarization system prompt: instructs the model to condense the
* conversation into a fixed, fully-populated structure rather than freeform
* bullets. The fixed structure guarantees coverage of the things a resuming
* model needs (original intent, pending work, the next step, critical context)
* and is stable across compaction cycles, so a prior checkpoint can be merged
* in place. The final rule keys off {@link SUMMARY_OPEN_TAG}: when the
* transcript already contains a prior checkpoint, the model consolidates rather
* than re-summarizing it verbatim (a cheap incremental-merge that needs no
* extra log/event machinery — the tag travels on the summary surface node).
*/
const SUMMARIZE_SYSTEM_PROMPT = [
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
'',
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
'',
'## Primary Request and Intent',
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
'',
'## Key Technical Concepts',
'- [technologies, frameworks, patterns, and conventions in play]',
'',
'## Files and Code',
'- [exact path: why it matters, key changes or snippets]',
'',
'## Errors and Fixes',
'- [error: how it was resolved, plus any related user feedback]',
'',
'## Pending Tasks',
'- [explicitly requested work not yet completed]',
'',
'## Current Work',
'- [precisely what was in progress at this checkpoint]',
'',
'## Next Step',
'- [the single next action, directly in line with the most recent request, or "(none)"]',
'',
'## Critical Context',
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
'',
'Rules:',
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
'- Do NOT mention this summarization process or that the context was compacted.',
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
].join('\n')
/**
* Framing prepended to the landed summary so a resuming model reads it as a
* checkpoint rather than a fresh user request, and continues the task from it.
* It summarizes an earlier span of the conversation; the messages that follow
* are the continuation. Because region compaction can be invoked manually, a
* surface may hold several checkpoints, so the framing does NOT claim that
* everything after it is recent or verbatim — only that the captured context
* should be built on, not restated.
*/
const CHECKPOINT_PREAMBLE =
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
/**
* Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or
* `undefined` for an acceptable finish. `FinishReason` is merge-extensible.
*
* Compaction fails CLOSED on a truncated summary: `error`, `aborted`, AND
* `max-tokens` all raise. Unlike an ordinary agent turn — where `max-tokens` is
* a normal "the model hit its budget" outcome the loop keeps — a summary cut off
* at the token cap is an INCOMPLETE checkpoint, and committing it would shadow
* (discard) the real history it summarizes. Raising here keeps the original
* surface intact (the caller appends `compact/end` with the error and the auto
* path proceeds with full history). `stop`/future kinds are accepted.
*/
function finishError(finish: FinishReason): Error | undefined {
switch (finish.kind) {
case 'error': {
const error = new Error(finish.message) as Error & { code?: string }
if (finish.code !== undefined) error.code = finish.code
return error
}
case 'aborted': {
const error = new Error('summarization stream aborted') as Error & { code?: string }
error.code = 'ABORTED'
return error
}
case 'max-tokens': {
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
error.code = 'MAX_TOKENS'
return error
}
default:
return undefined
}
}
/**
* Basic, dependency-light compaction backend. Defaults target a 128K context
* window, compacting at 80% utilization and retaining ~20K tokens of recent
* context.
*/
export class BasicCompactService extends CompactService {
/**
* `summarize()` reads `ctx.llm.stream()`. Declaring `llm` here lets the cordis
* context proxy resolve it when this service loads as a sibling of LlmService:
* without the inject, `this.ctx.llm` cannot be resolved from this fiber and
* compaction throws at runtime (see postmortem 0001).
*/
static inject = ['llm']
/** Resolved configuration (defaults applied). */
readonly config: ResolvedConfig
constructor(ctx: Context, config: BasicCompactConfig = {}) {
super(ctx)
this.config = resolveConfig(config)
if (this.config.auto) {
// Auto-compaction: delegate to compactIfNeeded before EVERY model call —
// every step, not just the first. A tool-heavy ReAct turn appends an
// assistant/message and a tool/result per step, so the surface (and the
// derived token count) grows within a turn; gating to step 1 would let a
// runaway turn overflow the window before the next turn's check. The
// listener stays agnostic — it owns NO threshold logic; compactIfNeeded is
// the single place that decides whether to compact, and its in-progress
// lock serializes concurrent attempts.
ctx.on('agent/request', async (agent: Agent, _turn, _step, request, next) => {
const before = this.estimateTokens(request.messages, request.system)
try {
const result = await this.compactIfNeeded(agent.session, request.system, request.model, request.signal)
if (result) {
// The surface has been mutated — re-derive messages for the call.
const rederived = agent.session.deriveMessages()
const afterTokens = this.estimateTokens(rederived, request.system)
ctx.logger.info(
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
`~${result.shadowedTokenCount} tokens) ` +
`${afterTokens} estimated tokens after compaction ` +
`(pressure was ~${before})`,
)
request.messages = rederived
}
} catch (error: unknown) {
// A failed compaction must not prevent the model call — proceed
// with the original messages.
const msg = error instanceof Error ? error.message : String(error)
ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`)
}
return next()
})
}
}
// ---- Token estimation (overridable hooks) ----
/**
* Estimate the token count of content blocks — char/4 with per-block
* overhead. Override in a subclass to plug in a real tokenizer.
*/
estimateContentTokens(blocks: readonly ContentBlock[]): number {
let tokens = 0
for (const block of blocks) {
switch (block.type) {
case 'text':
case 'reasoning':
tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD
break
case 'tool-call':
tokens += Math.ceil(block.name.length / 4)
+ Math.ceil(block.arguments.length / 4)
+ BLOCK_OVERHEAD
break
case 'tool-result':
tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
break
case 'image':
tokens += IMAGE_TOKEN_COST
break
default:
// Unknown block types (merge-extensible ContentBlockMap):
// estimate conservatively via JSON stringify.
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / 4)
}
}
return tokens
}
/**
* Estimate token count for a single session event. Returns 0 for non-message
* event types (boundaries, chunks, usage, errors, compact markers).
*/
estimateEventTokens(event: SessionEvent): number {
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'context/message':
case 'steering/message':
case 'tool/result':
return this.estimateContentTokens(event.data.content)
default:
return 0
}
}
/** Estimate total tokens across a list of messages plus optional system prompt. */
estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
let total = 0
for (const msg of messages) {
total += this.estimateContentTokens(msg.content)
total += ROLE_OVERHEAD
}
if (systemPrompt) total += Math.ceil(systemPrompt.length / 4)
return total
}
/**
* Summarize conversation text into content blocks via `ctx.llm.stream()`
* assembled through a `BlockAssembler` (the single model-call surface).
* Override in a subclass for a template or remote summarizer.
*
* Honors the adapter failure contract: an adapter may report a model failure
* by throwing from `stream()` (propagated here) OR by ending the stream with
* a `finish {kind:'error'|'aborted'}` chunk — the latter is re-thrown so a
* provider error never yields an empty summary.
*
* Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears
* down the in-flight summarization rather than orphaning the model call.
*/
async summarize(text: string, model: string, signal?: AbortSignal): Promise<ContentBlock[]> {
if (!model) throw new Error('no model available for summarization')
const assembler = new BlockAssembler()
const options: GenerateOptions = {
model,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
}],
system: SUMMARIZE_SYSTEM_PROMPT,
maxTokens: this.config.summarizationMaxTokens,
}
// exactOptionalPropertyTypes: only set `signal` when present — assigning
// `undefined` to an optional `signal?: AbortSignal` is a type error.
if (signal) options.signal = signal
for await (const chunk of this.ctx.llm.stream(options)) {
assembler.push(chunk)
}
const error = finishError(assembler.finish)
if (error) throw error
return assembler.message().content
}
// ---- Core API (implements the abstract contract) ----
/**
* The sole token-pressure gate: estimate the current history, and if it
* exceeds the threshold (`contextWindow * thresholdRatio`), compact the oldest
* surface nodes outside the `retainTokens` budget. The auto-compaction listener
* delegates here rather than pre-checking, so this is the only place the
* decision lives.
*/
override async compactIfNeeded(
session: Session,
systemPrompt?: string,
model?: string,
signal?: AbortSignal,
): Promise<CompactionResult | null> {
const messages = session.deriveMessages()
const totalTokens = this.estimateTokens(messages, systemPrompt)
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
if (totalTokens < threshold) return null
// Walk surface nodes tail→head, accumulating token estimates.
const nodes = session.surface.nodes
if (nodes.length === 0) return null
const retainBudget = this.config.retainTokens
// ALWAYS retain the IN-FLIGHT turn's surface nodes verbatim — its initiating
// user request and any mid-turn tool results are the exact input/observation
// the model is acting on right now, even if they exceed the soft retain
// budget. Compacting them would hand the model a lossy summary of its own
// current task. Only nodes in PRIOR (closed) turns are eligible to compact;
// `protectedIdx` is the first surface node of the open turn (or `nodes.length`
// when the open turn has no surface nodes yet, e.g. before step 1).
const protectedIdx = this._openTurnFirstSurfaceIdx(session, nodes)
if (protectedIdx === 0) return null
let accumulated = 0
let cutoffIdx = -1
// Seed the accumulator with the protected suffix so the retain budget is
// measured against what actually stays, then look for a cutoff only among
// the older (compactable) nodes.
for (let i = nodes.length - 1; i >= protectedIdx; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = session.events[nodes[i]!.seq]
if (event) accumulated += this.estimateEventTokens(event)
}
for (let i = protectedIdx - 1; i >= 0; i--) {
// nodes[i] bounded by i >= 0 and i < nodes.length — never undefined.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const node = nodes[i]!
const event = session.events[node.seq]
/* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
if (!event) continue
accumulated += this.estimateEventTokens(event)
if (accumulated > retainBudget) {
cutoffIdx = i
break
}
}
// If we walked the entire compactable range without exceeding the budget,
// everything outside the protected in-flight turn fits — no compaction
// needed.
if (cutoffIdx === -1) return null
// Snap the cutoff to a step-aligned end so the compacted region never splits
// a step (which would orphan a tool-call or its tool/result). The token
// budget is a soft target. PREFER snapping FORWARD (compact slightly more
// recent context to reach a clean boundary), but never into the protected
// in-flight turn: if the forward snap would reach `protectedIdx`, fall back
// to snapping BACKWARD to the previous step-aligned end (compact slightly
// less), and decline only if no step-aligned end exists in the compactable
// range at all.
const events = session.events
cutoffIdx = this._snapCutoff(events, nodes, cutoffIdx, protectedIdx)
if (cutoffIdx === -1) return null
// nodes is non-empty (checked above) and cutoffIdx is a valid index.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const firstSeq = nodes[0]!.seq
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cutoffSeq = nodes[cutoffIdx]!.seq
const resolvedModel = model ?? ''
return this.compactRegion(session, firstSeq, cutoffSeq, resolvedModel, signal)
}
override async compactRegion(
session: Session,
start: number,
end: number,
model: string,
signal?: AbortSignal,
): Promise<CompactionResult> {
// Resolve the range by surface POSITION, not numeric seq interval. A prior
// replace lands a fresh high-seq summary node AT the shadowed range's
// position, so the surface order (head→tail) no longer tracks seq order —
// `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the
// ordered node list and slicing it is the only correct way to read a range;
// a `node.seq >= start && node.seq <= end` interval test would mis-collect
// nodes (and `start > end` would falsely reject) once that happens.
const nodes = session.surface.nodes
const startIdx = nodes.findIndex(n => n.seq === start)
const endIdx = nodes.findIndex(n => n.seq === end)
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
if (startIdx > endIdx) {
throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
}
// The region must contain whole steps, never split a step's
// assistant-message tool-calls from their tool/results (which would orphan
// one side and produce a transcript every provider rejects). A boundary is
// valid when it sits on a step edge or on a node that belongs to no step
// (pre-step user message, inter-step steering, injection context); an `end`
// inside an open (unclosed) tail step is also rejected — its tool-calls have
// no results yet. See dsh-session's step-boundary predicates.
const events = session.events
if (!isStepAlignedStart(events, start)) {
throw new Error(`compactRegion: start seq ${start} is not on a step boundary (would split a step's tool-call/result pair)`)
}
if (!isStepAlignedEnd(events, end)) {
throw new Error(`compactRegion: end seq ${end} is not on a step boundary (would split a step, or the step is still open)`)
}
if (this._isCompactionInProgress(session)) {
throw new Error('compaction already in progress')
}
// Compaction's events (compact/* and the replacement user/message) must be
// turn-enclosed: the session-log contract rejects any plugin event appended
// outside an open turn. Auto-compaction satisfies this — it runs inside the
// `agent/request` waterfall, strictly between a turn's start and end. A
// manual call on a fully-closed session has no turn to enclose the events,
// so reject rather than emit an un-enclosed run.
const turn = this._openTurn(session)
if (turn === null) {
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
}
// Slice the ordered surface nodes [startIdx, endIdx] inclusive — the
// shadowed range is positional, so this is the set the replace op covers.
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq)
// --- Acquire lock ---
const startEvent = session.append('compact/start', { turn })
try {
// --- Extract text and summarize ---
const text = this._extractText(session, shadowedSeqs)
const summaryModel = this.config.summarizationModel || model
const summary = await this.summarize(text, summaryModel, signal)
// Estimate token count of the shadowed content for provenance.
let shadowedTokenCount = 0
for (const seq of shadowedSeqs) {
// seq comes from a surface node — always a valid log index by construction.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
shadowedTokenCount += this.estimateEventTokens(session.events[seq]!)
}
// --- Provenance record (log-only) ---
const summaryEvent = session.append('compact/summary', {
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
})
// --- Surface replacement ---
// The user/message directly shadows all compacted surface nodes with a
// single replace op. It is the ONLY surface event in the compaction
// sequence — compact/start, compact/summary, and compact/end are log-only
// (surfaceOp is rejected by the compiler for non-SurfaceEventType).
// The landed content is FRAMED (checkpoint preamble + tag-wrapped summary);
// the compact/summary provenance event above holds the raw model output.
session.append('user/message', {
content: this._frameSummary(summary),
source: { kind: 'plugin', plugin: 'compact' },
}, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
// --- Release lock (log-only) ---
// Appended LAST so the lock brackets the WHOLE operation: a crash between
// compact/start and here leaves a detectable orphaned lock (a compact/start
// with no matching compact/end) rather than a compact/end that falsely
// claims compaction finished before the surface replacement landed.
const endEvent = session.append('compact/end', { turn })
return {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
}
} catch (error: unknown) {
// Always release the lock — append compact/end with the error so a
// wedged lock is impossible.
const msg = error instanceof Error ? error.message : String(error)
session.append('compact/end', { turn, error: msg })
throw error
}
}
// ---- Internal helpers ----
/**
* The index of the first surface node that belongs to the currently-open turn
* — the boundary of the protected, never-compacted suffix. Returns
* `nodes.length` when the open turn has contributed no verbatim surface node
* yet (e.g. before step 1 appends anything), so the whole surface is
* compaction-eligible up to the tail.
*
* The in-flight turn's verbatim nodes (its request, mid-turn assistant
* messages, tool results — all `append` ops) form a CONTIGUOUS run at the TAIL
* of the surface. A compaction replacement node, though also appended during
* the open turn (seq > `turn/start`), lands at the position of the older range
* it shadowed — earlier in the surface, NOT in the tail run — so it is itself
* compaction-eligible (a later cycle can merge it). The protected suffix is
* therefore the contiguous tail run of nodes whose seq exceeds the open turn's
* `turn/start`, found by walking from the tail. With no open turn (a closed
* session — only manual `compactRegion`, never the auto path), nothing is
* protected and this returns `nodes.length`.
*/
private _openTurnFirstSurfaceIdx(session: Session, nodes: readonly SurfaceNode[]): number {
const openTurn = this._openTurn(session)
if (openTurn === null) return nodes.length
// Find the open turn's turn/start seq (scanning back from the tail).
let turnStartSeq = -1
for (let i = session.events.length - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const e = session.events[i]!
if (e.type === 'turn/start' && e.data.turn === openTurn) { turnStartSeq = e.seq; break }
}
/* v8 ignore next -- _openTurn returned non-null, so its turn/start exists */
if (turnStartSeq === -1) return nodes.length
// Walk from the tail while nodes belong to the open turn (seq > turn/start),
// taking only the CONTIGUOUS run — a compaction summary node appended this
// turn but sitting earlier in the surface stops the run and stays eligible.
let idx = nodes.length
while (idx > 0 && nodes[idx - 1]!.seq > turnStartSeq) idx -= 1 // eslint-disable-line @typescript-eslint/no-non-null-assertion
return idx
}
/**
* Snap a raw token-budget cutoff index to a step-aligned end among the nodes
* BELOW the protected suffix (`protectedIdx`, the first node of the in-flight
* turn). Returns the snapped index, or `-1` if no step-aligned end exists in
* the compactable range (e.g. it is empty, or its only content is an open tail
* step).
*
* Prefers snapping FORWARD to the next step-aligned end (compact slightly more
* recent context for a clean boundary); if the forward scan reaches
* `protectedIdx` without finding one, falls back to scanning BACKWARD from the
* raw cutoff (compact slightly less). The protected suffix is never returned —
* it stays verbatim so the model sees its current task, not a summary.
*/
private _snapCutoff(
events: readonly SessionEvent[],
nodes: readonly SurfaceNode[],
rawCutoffIdx: number,
protectedIdx: number,
): number {
// Forward: the next step-aligned end strictly below the protected suffix.
for (let i = rawCutoffIdx; i < protectedIdx; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isStepAlignedEnd(events, nodes[i]!.seq)) return i
}
// Backward: the nearest step-aligned end at or below the raw cutoff.
for (let i = rawCutoffIdx - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isStepAlignedEnd(events, nodes[i]!.seq)) return i
}
return -1
}
/**
* Frame the raw summary blocks into the content that lands on the surface:
* a checkpoint preamble (so a resuming model reads it as a checkpoint, not a
* fresh user request) followed by the summary wrapped in
* {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior
* checkpoint detectable in the transcript on the next compaction cycle, which
* triggers the merge rule in the summarization prompt. The raw, unframed
* `summary` is preserved separately on the `compact/summary` provenance event.
*/
private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
return [
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
...summary,
{ type: 'text', text: SUMMARY_CLOSE_TAG },
]
}
/**
* Whether a compaction is currently in progress for `session` — an unmatched
* `compact/start` (no later `compact/end`) WITHIN the current turn.
*
* The scan is scoped to the current turn: walking back from the tail it stops
* at the first `turn/end` (the boundary closing the prior turn). A
* `compact/start` left orphaned by a crash mid-compaction lives in a turn that
* persistence repair then closes with a synthetic `turn/end`; scoping here so
* that a stale orphan from a PAST turn cannot wedge compaction forever (it sits
* before the nearest `turn/end`, so the scan never reaches it). An in-progress
* compaction's `compact/start` is always in the still-open current turn,
* before any `turn/end`, so it is still detected.
*/
private _isCompactionInProgress(session: Session): boolean {
const events = session.events
for (let i = events.length - 1; i >= 0; i--) {
// Index bounded by i >= 0 and i < events.length — never undefined.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const e = events[i]!
if (e.type === 'compact/start') return true
if (e.type === 'compact/end') break
// A turn/end bounds the scan: anything before it belongs to a prior
// (closed) turn and cannot be an in-progress compaction of THIS turn.
if (e.type === 'turn/end') break
}
return false
}
/**
* The turn number of the currently OPEN turn — a `turn/start` not yet
* followed by its `turn/end` — or `null` if the session has no open turn.
*
* Compaction's events must be enclosed in a turn, so scanning back from the
* tail: a `turn/start` means that turn is open (return it); a `turn/end` means
* the most recent turn already closed (return null). The whole compaction
* sequence (compact/start … compact/end) is stamped with this turn.
*/
private _openTurn(session: Session): number | null {
for (let i = session.events.length - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const e = session.events[i]!
if (e.type === 'turn/start') return e.data.turn
if (e.type === 'turn/end') return null
}
return null
}
/**
* Extract plain-text conversation from a set of surface node seqs, for
* feeding into the summarization model. Walks events in log order so the
* summary captures chronological flow.
*/
private _extractText(session: Session, seqs: number[]): string {
const lines: string[] = []
// Walk seqs in the order given (surface order, as compactRegion slices the
// surface-node list) — NOT ascending log-seq order. After a replace the
// summary node carries a fresh high seq while sitting at the head of the
// surface before older retained lower-seq nodes, so a log-order scan would
// feed the transcript out of order and break the checkpoint-merge prompt.
for (const seq of seqs) {
const event = session.events[seq]
/* v8 ignore next -- seq is a surface-node seq, always a valid log index by construction */
if (!event) continue
switch (event.type) {
case 'user/message': {
const text = this._blocksToText(event.data.content)
if (text) lines.push(`User: ${text}`)
break
}
case 'assistant/message': {
const text = this._blocksToText(event.data.content)
if (text) lines.push(`Assistant: ${text}`)
break
}
case 'tool/result': {
const text = this._blocksToText(event.data.content)
const label = event.data.isError ? 'Tool error' : 'Tool result'
if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
break
}
case 'context/message': {
const text = this._blocksToText(event.data.content)
if (text) lines.push(`[Context: ${text}]`)
break
}
case 'steering/message': {
const text = this._blocksToText(event.data.content)
if (text) lines.push(`[Steering: ${text}]`)
break
}
// SessionEventMap is merge-extensible — unknown types are
// non-message events that carry no extractable text.
/* v8 ignore next 2 -- seqs only name surface nodes, always one of the 5 handled SurfaceEventTypes; unreachable */
default:
break
}
}
return lines.join('\n\n')
}
/**
* Render content blocks to a single plain-text string for the summarization
* prompt. Text and reasoning contribute their text; every other block type
* contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`,
* …) so the summarizer is told what non-text content existed in the region
* rather than silently losing it. Blocks join with newlines; empty-text
* blocks contribute nothing.
*/
private _blocksToText(blocks: readonly ContentBlock[]): string {
const parts: string[] = []
for (const block of blocks) {
switch (block.type) {
case 'text':
if (block.text) parts.push(block.text)
break
case 'reasoning':
if (block.text) parts.push(`[reasoning: ${block.text}]`)
break
case 'tool-call':
parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
break
case 'tool-result': {
const inner = this._blocksToText(block.content)
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
break
}
case 'image':
parts.push('[image]')
break
// ContentBlockMap is merge-extensible — render an unknown block as a
// bare type-tagged placeholder so a plugin-added block type is still
// signalled to the summarizer rather than dropped.
default:
parts.push(`[${(block as ContentBlock).type}]`)
}
}
return parts.join('\n')
}
}
export default BasicCompactService

View File

@@ -0,0 +1,44 @@
/**
* Configuration vocabulary for the basic compaction backend.
*
* Every tunable lives here, in the implementation — the abstract contract
* (`@deepseek-ai/dsh-compact`) carries no config, because thresholds and
* retention policy are HOW decisions a different backend would make
* differently.
*
* @module @deepseek-ai/dsh-compact-basic/types
*/
/** Backend configuration — all optional with sensible defaults. */
export interface BasicCompactConfig {
/** Context window size in tokens (default 128000). */
contextWindow?: number
/** Compact when estimated token usage exceeds this fraction of context window (default 0.8). */
thresholdRatio?: number
/** Number of tokens of recent context to retain during compaction (default 20480). */
retainTokens?: number
/** Model to use for summarization (default '' — uses the agent's model). */
summarizationModel?: string
/** Maximum tokens for the summarization response (default 2048). */
summarizationMaxTokens?: number
/** Enable automatic compaction on the `agent/request` waterfall (default true). */
auto?: boolean
}
/** Resolved config with all defaults applied. */
export type ResolvedConfig = Required<BasicCompactConfig>
/** Default configuration values. */
export const DEFAULTS: ResolvedConfig = {
contextWindow: 128000,
thresholdRatio: 0.8,
retainTokens: 20480,
summarizationModel: '',
summarizationMaxTokens: 2048,
auto: true,
}
/** Apply defaults to a partial config. */
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
return { ...DEFAULTS, ...config }
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -19,7 +19,7 @@ Both methods are **abstract** — the backend owns the entire strategy (token es
| Member | Semantics |
|---|---|
| `compactIfNeeded(session, systemPrompt?, model?, signal?)` | 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, signal?)` | 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`. |
| `compactRegion(session, start, end, model, signal?)` | 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` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
Both methods take an optional `signal: AbortSignal`. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it.

View File

@@ -89,6 +89,16 @@ export abstract class CompactService extends Service {
* summarizes their content and appends a replacement surface node. Used by the
* (future) `/compact` tool and internally by {@link compactIfNeeded}.
*
* The region MUST contain whole steps — `start` and `end` must each sit on a
* step boundary (the first / last surface node of a step) or on a node that
* belongs to no step (a pre-step user message, inter-step steering, or an
* injection context message). A boundary that falls INSIDE a step would split
* that step's `assistant/message` tool-calls from their `tool/result`s, leaving
* the rehydrated transcript with a dangling tool-call or an orphaned
* tool-result that every provider rejects. An `end` inside an open (unclosed)
* tail step is likewise invalid — its tool-calls have no results yet.
* `dsh-session` exports `isStepAlignedStart` / `isStepAlignedEnd` for this check.
*
* @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.
@@ -97,8 +107,12 @@ export abstract class CompactService extends Service {
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
* so an abort/dispose tears down the in-flight summarization rather than
* leaving an orphaned model call running past the cancellation.
* @throws if compaction is already in progress, or if `start`/`end` are not
* valid surface nodes, or if `start > end`.
* @throws if compaction is already in progress, if `start`/`end` are not
* valid surface nodes, if `start` is positioned after `end` on the surface
* (the range is a surface-POSITION span, not a numeric seq interval — a
* prior replace can leave the surface non-monotonic in seq order), or if
* either boundary is not step-aligned (would split a step's tool-call/result
* pair).
*/
abstract compactRegion(
session: Session,

View File

@@ -48,9 +48,16 @@ export interface CompactionResult {
endSeq: number
/** The summary content blocks produced by the backend. */
summary: ContentBlock[]
/** The seq range that was shadowed [start, end] inclusive. */
/**
* The surface-boundary pair that was shadowed: the seqs of the first
* (`start`) and last (`end`) surface nodes of the replaced range. A
* surface-POSITION span, not a numeric seq interval — after a prior replace
* lands a fresh high-seq summary node at an older range's position, `start`
* can be GREATER than `end`. {@link CompactionResult.shadowedSeqs} is the
* authoritative set of shadowed nodes, in surface order.
*/
shadowedRange: { start: number; end: number }
/** The seq numbers of all shadowed surface nodes. */
/** The seqs of all shadowed surface nodes, in surface order. */
shadowedSeqs: number[]
/** Estimated token count of the shadowed content. */
shadowedTokenCount: number

View File

@@ -19,6 +19,7 @@ export { isJsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { isStepAlignedStart, isStepAlignedEnd } from './step-boundary.ts'
declare module 'cordis' {
interface Context {

View File

@@ -0,0 +1,97 @@
/**
* Step-boundary predicates over a session log: is a given surface node a SAFE
* place to start or end a region that will be collapsed (e.g. by compaction)?
*
* The invariant a consumer needs: a collapsed region must NOT partially overlap
* a step. A step's surface nodes form a contiguous run, and a region must
* contain either ALL of a step's nodes or NONE of them — otherwise it can split
* an `assistant/message`'s `tool-call` blocks from their `tool/result`s, leaving
* the rehydrated transcript with a dangling tool-call or an orphaned tool-result
* (which every provider rejects). This is the compaction-time mirror of the
* crash-recovery imbalance that {@link interruptedTurnClosers} repairs on load.
*
* Nodes that belong to NO step — a pre-step `user/message` (drained before the
* first `step/start`), inter-step `steering/message`, or an injection
* `context/message` (wrapped in a bare `turn/start → context/message → turn/end`
* with no step) — carry no tool pairing and are free boundaries on both sides.
*
* The scans classify each neighbor event into three buckets: a turn/step
* BOUNDARY marker (the region edge is clean), a SURFACE node (the region edge
* is mid-step), or NOISE to skip (`assistant/chunk`, the log-only `compact/*`
* records, and any future non-surface event). "Surface node" is decided by the
* shared {@link isSurfaceEvent} guard so the two notions can't drift.
*
* @module @deepseek-ai/dsh-session/step-boundary
*/
import type { SessionEvent } from './types.ts'
import { isSurfaceEvent } from './surface.ts'
/** Turn/step boundary marker types — the walls the scans stop on. */
const BOUNDARY_TYPES = new Set<string>(['turn/start', 'turn/end', 'step/start', 'step/end'])
/**
* Whether the surface node at `seq` is a SAFE START for a collapsed region —
* i.e. it is the first surface node of its step, or it belongs to no step at
* all (a free inter-step / pre-step / injection node).
*
* Scans BACKWARD from `seq`, skipping noise, and stops at the first significant
* event: a turn/step boundary marker ⇒ aligned (nothing of `seq`'s step lies
* before it), a surface node ⇒ NOT aligned (a predecessor surface node sits in
* the same step, so starting here would orphan it), start-of-log ⇒ aligned.
*
* No open-step check is needed on the start side: an open (unclosed) step can
* only ever be the LAST turn's last step, never before a valid region start.
*/
export function isStepAlignedStart(events: readonly SessionEvent[], seq: number): boolean {
for (let i = seq - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = events[i]!
if (BOUNDARY_TYPES.has(event.type)) return true
if (isSurfaceEvent(event)) return false
}
return true
}
/**
* Whether the surface node at `seq` is a SAFE END for a collapsed region —
* i.e. it is the last surface node of a CLOSED step, or it belongs to no step
* at all.
*
* Scans FORWARD from `seq`, skipping noise, and stops at the first significant
* event: a turn/step boundary marker ⇒ aligned (the step/turn closes after
* `seq`, or a new one begins because `seq` was inter-step), a surface node ⇒
* NOT aligned (a later surface node sits in the same step). Reaching
* end-of-log is aligned ONLY when `seq` is not inside an OPEN step — an open
* trailing step's `tool-call`s have no `tool/result`s yet, so collapsing it
* would defer the orphan to when those results land later. {@link isInOpenStep}
* decides that via a backward scan.
*/
export function isStepAlignedEnd(events: readonly SessionEvent[], seq: number): boolean {
for (let i = seq + 1; i < events.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const event = events[i]!
if (BOUNDARY_TYPES.has(event.type)) return true
if (isSurfaceEvent(event)) return false
}
// End of log: aligned only if `seq` is not inside a still-open step.
return !isInOpenStep(events, seq)
}
/**
* Whether `seq` sits inside an OPEN step — a `step/start` with no later
* `step/end`. Only meaningful at the tail (the EOL branch of
* {@link isStepAlignedEnd}): scans BACKWARD for the nearest turn/step boundary.
* The nearest one being `step/start` means a step opened before `seq` and never
* closed (no `step/end` lies after `seq`, or the forward scan would not have
* reached EOL) — so `seq` is mid-open-step. Any other nearest boundary (or none)
* means `seq` is inter-step / pre-step.
*/
function isInOpenStep(events: readonly SessionEvent[], seq: number): boolean {
for (let i = seq - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const type = events[i]!.type
if (BOUNDARY_TYPES.has(type)) return type === 'step/start'
}
return false
}

View File

@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { isStepAlignedStart, isStepAlignedEnd } from '../src/index.ts'
import type { SessionEvent } from '../src/index.ts'
/**
* Unit coverage for the step-alignment predicates. They decide whether a
* surface node is a safe START / END for a collapsed region (compaction): a
* region must contain whole steps, never split an `assistant/message`'s
* tool-calls from their `tool/result`s. Nodes belonging to no step (pre-step
* user message, inter-step steering, injection context) are free boundaries.
*
* Builders mirror the agent loop's real append order so the fixtures are
* representative: queued user messages land BEFORE `step/start`; within a step
* the order is `assistant/message` then `tool/result`(s); injection turns are a
* bare `turn/start → context/message → turn/end` with no step.
*/
const SURFACE = { surfaceOp: 'append' as const }
/** A closed turn with one closed step holding an assistant + its tool result. */
function toolStepLog(): SessionEvent[] {
return [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE },
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [
{ type: 'text', text: 'calling' },
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
] }, ...SURFACE },
{ type: 'tool/call', seq: 4, time: 4, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' } },
{ type: 'tool/result', seq: 5, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, ...SURFACE },
{ type: 'step/end', seq: 6, time: 6, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 7, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
]
}
describe('isStepAlignedStart', () => {
it('is true for a pre-step user/message (belongs to no step)', () => {
// seq 1 user/message sits before step/start at seq 2 → free boundary.
expect(isStepAlignedStart(toolStepLog(), 1)).toBe(true)
})
it('is true for the first surface node of a step (the assistant/message)', () => {
// Backward from seq 3 the first significant event is step/start → aligned.
expect(isStepAlignedStart(toolStepLog(), 3)).toBe(true)
})
it('is false for a tool/result whose assistant/message precedes it in the same step', () => {
// Backward from seq 5 the first significant event is the assistant/message
// surface node (seq 3) → starting here would orphan that assistant's call.
expect(isStepAlignedStart(toolStepLog(), 5)).toBe(false)
})
it('is true at start-of-log (nothing precedes)', () => {
const log: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE },
]
expect(isStepAlignedStart(log, 0)).toBe(true)
})
it('skips noise (assistant/chunk, compact/* records) when scanning back', () => {
// A compacted region landed compact/* log-only records between the prior
// step boundary and this surface node; they must be skipped, not treated as
// walls. Backward from seq 4 skips compact/end, compact/summary, compact/start
// and stops at step/start (seq 0) → aligned.
const log: SessionEvent[] = [
{ type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } },
{ type: 'compact/start', seq: 1, time: 1, data: { turn: 1 } } as unknown as SessionEvent,
{ type: 'compact/summary', seq: 2, time: 2, data: { summary: [], shadowedRange: { start: 0, end: 0 }, shadowedSeqs: [], shadowedTokenCount: 0 } } as unknown as SessionEvent,
{ type: 'compact/end', seq: 3, time: 3, data: { turn: 1 } } as unknown as SessionEvent,
{ type: 'assistant/message', seq: 4, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE },
]
expect(isStepAlignedStart(log, 4)).toBe(true)
})
})
describe('isStepAlignedEnd', () => {
it('is true for the last surface node of a closed step (the tool/result)', () => {
// Forward from seq 5 the first significant event is step/end → aligned.
expect(isStepAlignedEnd(toolStepLog(), 5)).toBe(true)
})
it('is false for an assistant/message with a later tool/result in the same step', () => {
// Forward from seq 3 the first significant event is the tool/result surface
// node (seq 5) → ending here would strand that result.
expect(isStepAlignedEnd(toolStepLog(), 3)).toBe(false)
})
it('is true for a pre-step user/message (next significant event is step/start)', () => {
expect(isStepAlignedEnd(toolStepLog(), 1)).toBe(true)
})
it('is false at EOL when the node is inside an open (unclosed) step', () => {
// step/start then an assistant tool-call, but no step/end / tool/result yet
// (mid-flight). Ending the region on seq 3 would summarize away a tool-call
// whose result lands later → orphan. EOL + open step ⇒ not aligned.
const log: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
] }, ...SURFACE },
]
expect(isStepAlignedEnd(log, 2)).toBe(false)
})
it('is false at EOL when the node is inside an open step, skipping noise on the back-scan', () => {
// The open-step back-scan must skip non-boundary events (here an
// assistant/chunk) before it reaches step/start. Without the skip it would
// mis-read the chunk as the nearest "boundary" and never confirm the open step.
const log: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } },
{ type: 'assistant/message', seq: 3, time: 3, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
] }, ...SURFACE },
]
expect(isStepAlignedEnd(log, 3)).toBe(false)
})
it('is true at EOL when the node is a trailing inter-step node (step already closed)', () => {
// A steering message appended after step/end, at the tail. Backward the
// nearest boundary is step/end → not in an open step → aligned.
const log: SessionEvent[] = [
{ type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 1, time: 1, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, ...SURFACE },
{ type: 'step/end', seq: 2, time: 2, data: { turn: 1, step: 1 } },
{ type: 'steering/message', seq: 3, time: 3, data: { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, ...SURFACE },
]
expect(isStepAlignedEnd(log, 3)).toBe(true)
})
it('is true at EOL when no step ever opened (start-of-log fallback in open-step check)', () => {
// A lone surface node, no turn/step markers at all → not in an open step.
const log: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, ...SURFACE },
]
expect(isStepAlignedEnd(log, 0)).toBe(true)
})
it('skips noise (assistant/chunk) when scanning forward', () => {
// assistant/chunk events precede the assistant/message in a real step; the
// forward scan from an inter-step node must skip them and stop on step/start.
const log: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 0, data: { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, ...SURFACE },
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 2, time: 2, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } },
]
// Forward from seq 0 hits step/start at seq 1 → aligned (noise after is moot).
expect(isStepAlignedEnd(log, 0)).toBe(true)
})
})
describe('step-alignment on an injection turn (no step)', () => {
// An idle inject() wraps a context/message in a bare turn/start → context/message
// → turn/end with NO step/start. The context node is a free boundary both ways.
const injectionLog = (): SessionEvent[] => [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } },
{ type: 'context/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, ...SURFACE },
{ type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
]
it('start: aligned (backward hits turn/start)', () => {
expect(isStepAlignedStart(injectionLog(), 1)).toBe(true)
})
it('end: aligned (forward hits turn/end)', () => {
expect(isStepAlignedEnd(injectionLog(), 1)).toBe(true)
})
})

View File

@@ -162,9 +162,6 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
trace.surface.push(event.seq)
} else {
const { start, end } = se.surfaceOp
if (start > end) {
throw new InvariantError(`surface replace: start ${start} must be <= end ${end}`)
}
const startIdx = trace.surface.indexOf(start)
if (startIdx === -1) {
throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)

View File

@@ -530,16 +530,17 @@ describe('surface invariants', () => {
}).toThrow(/unknown seq 2/)
})
it('rejects replace op with start > end', async () => {
it('rejects a replace whose start is positioned after its end on the surface', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
// start > end is invalid (reversed order).
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
// Reversed range: start seq 3 is at a later surface position than end seq 2.
expect(() => {
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2] })
}).toThrow(/must be <= end/)
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] })
}).toThrow(/is after end seq 2 .* on the surface/)
})
it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => {
@@ -608,6 +609,23 @@ describe('surface invariants', () => {
}).toThrow(/is after end seq 4 .* on the surface/)
})
it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
// Replace node 2 (position 0) with seq 4 — surface becomes [4, 3], so the
// head seq (4) is numerically GREATER than the tail seq (3): the surface is
// not seq-ordered. A replace spanning start=4 (pos 0) … end=3 (pos 1) is
// valid positionally and must be accepted even though start seq > end seq.
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
expect(() => {
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 4, end: 3 }, sourceEventSeqs: [4, 3] }) // seq 5
}).not.toThrow()
})
it('rejects a replace that omits sourceEventSeqs entirely', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()

21
pnpm-lock.yaml generated
View File

@@ -130,6 +130,27 @@ 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-basic:
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-compact':
specifier: workspace:^
version: link:../compact
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@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':

View File

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

View File

@@ -38,6 +38,7 @@
{ "path": "./packages/bash/bash-local" },
{ "path": "./packages/bash/tool-bash" },
{ "path": "./packages/compact/compact" },
{ "path": "./packages/compact/compact-basic" },
{ "path": "./packages/support/invariants" },
{ "path": "./packages/ui/acp" },
{ "path": "./packages/ui/acp-agent" },