2026-06-25 17:30:42 +08:00
/ * *
2026-07-13 23:27:00 +08:00
* Basic compaction backend . It estimates request pressure , retains a recent
* tool - balanced surface tail , summarizes the older head through a one - shot model
* call , and replaces that head with one checkpoint . Auto - compaction runs before
* every step so a growing turn can compact its earlier closed steps .
2026-06-25 17:30:42 +08:00
* @module @deepseek - ai / dsh - compact - basic
* /
import { Context } from 'cordis'
2026-07-15 13:22:44 +08:00
import { CompactService , renderTranscript , toolPairingBalancedAfter , toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
2026-06-25 17:30:42 +08:00
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'
2026-06-26 08:59:33 +08:00
import type { Session , SessionEvent } from '@deepseek-ai/dsh-session'
2026-06-25 17:30:42 +08:00
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'
2026-07-01 22:10:49 +08:00
export { resolveConfig } from './types.ts'
2026-06-25 17:30:42 +08:00
/** Per-block structural overhead for JSON framing / type tag. */
const BLOCK_OVERHEAD = 4
/** 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>'
/ * *
2026-07-13 23:27:00 +08:00
* Fixed summary structure for resumable checkpoints . A tagged prior checkpoint
* is merged with newer history instead of copied forward verbatim .
2026-06-25 17:30:42 +08:00
* /
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' )
2026-07-12 03:36:43 +08:00
/** Framing that makes a landed summary established context rather than a new request. */
2026-06-25 17:30:42 +08:00
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.'
/ * *
2026-07-13 23:27:00 +08:00
* Map a terminal summary failure to an error . A max - token finish is rejected
* because committing an incomplete checkpoint would shadow the full history .
2026-06-25 17:30:42 +08:00
* /
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
}
}
/ * *
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
* Basic , dependency - light compaction backend : estimates the surface ' s token
* footprint , summarizes the stale prefix through the model , and shadows it
* behind a durable checkpoint . Every threshold / budget knob is required config
* ( { @link BasicCompactConfig } ) ; the estimator ' s text density is the
* ` charsPerToken ` knob .
2026-06-25 17:30:42 +08:00
* /
export class BasicCompactService extends CompactService {
static inject = [ 'llm' ]
2026-07-01 22:10:49 +08:00
/** Resolved configuration (`auto` defaulted). */
2026-06-25 17:30:42 +08:00
readonly config : ResolvedConfig
2026-07-01 22:10:49 +08:00
constructor ( ctx : Context , config : BasicCompactConfig ) {
2026-06-25 17:30:42 +08:00
super ( ctx )
this . config = resolveConfig ( config )
if ( this . config . auto ) {
2026-07-13 23:27:00 +08:00
// Check before every step so a single growing turn can compact earlier closed steps.
// This serial pre-step seam mutates the surface outside the pending step.
2026-07-08 20:30:10 +08:00
ctx . on ( 'agent/pre-step' , async ( agent : Agent , _turn : number , _step : number , fullSystemPrompt : string , sessionPrefix : readonly Message [ ] , signal : AbortSignal ) = > {
2026-06-25 17:30:42 +08:00
try {
2026-07-08 20:30:10 +08:00
const result = await this . compactIfNeeded ( agent , fullSystemPrompt , sessionPrefix , signal )
2026-06-25 17:30:42 +08:00
if ( result ) {
2026-07-08 20:30:10 +08:00
const after = this . estimatePressure ( agent . session , fullSystemPrompt , sessionPrefix )
2026-06-25 17:30:42 +08:00
ctx . logger . info (
` compaction: shadowed ${ result . shadowedSeqs . length } surface nodes ` +
` (seqs ${ result . shadowedRange . start } - ${ result . shadowedRange . end } , ` +
` ~ ${ result . shadowedTokenCount } tokens) ` +
2026-06-26 08:59:33 +08:00
` → ${ after } estimated tokens after compaction ` ,
2026-06-25 17:30:42 +08:00
)
}
} catch ( error : unknown ) {
2026-06-26 08:59:33 +08:00
// A failed compaction must not prevent the model call — the surface is
// untouched on failure, so the loop derives the full history and the
// call proceeds.
2026-06-25 17:30:42 +08:00
const msg = error instanceof Error ? error.message : String ( error )
ctx . logger . warn ( ` compaction failed: ${ msg } ; proceeding with full history ` )
}
} )
}
}
// ---- Token estimation (overridable hooks) ----
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
// TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
// count — a real tokenizer, or the provider's post-response `usage` (input
// tokens) fed back as a correction — so threshold decisions match the
// model's actual budget.
2026-06-25 17:30:42 +08:00
/ * *
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
* Estimate the token count of content blocks — chars divided by the
* ` charsPerToken ` config , with per - block overhead . Override in a subclass to
* plug in a real tokenizer .
Gate JSDoc completeness on every package export
New doc-sync gate verify-export-jsdoc walks every module-level exported
name under packages/*/*/src and requires description prose everywhere,
plus @param per parameter and @returns on non-void annotated returns for
function-like exports, public class methods, properties, and accessors.
The parsing + check helpers move out of gen-cordis-catalog.ts into a
shared scripts/jsdoc.ts so 'documented' means one thing on both gated
surfaces.
Deliberate exemptions (documented in the RFC): heritage-declared class
members (the seam declaration is the doc's one home — the one checker
query in an otherwise pure-AST walk), cordis plugin-protocol slots
(name/inject/reusable/Config/apply, top-level and static), constructors,
overload implementations, declare-module augmentation bodies, and
re-export statements (checked at the defining module).
The 203 under-documented exports the gate found at adoption are filled
in this change, so the gate lands green; generated catalogs/graphs are
regenerated for the shifted line pointers.
RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
2026-07-06 22:09:30 +08:00
*
* @param blocks - the blocks to estimate ; ` tool-result ` blocks recurse into
* their nested content , and unknown ( merge - extended ) types fall back to
* their JSON - stringified length .
* @returns the estimated token count .
2026-06-25 17:30:42 +08:00
* /
estimateContentTokens ( blocks : readonly ContentBlock [ ] ) : number {
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
const { charsPerToken } = this . config
2026-06-25 17:30:42 +08:00
let tokens = 0
for ( const block of blocks ) {
switch ( block . type ) {
case 'text' :
case 'reasoning' :
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
tokens += Math . ceil ( block . text . length / charsPerToken ) + BLOCK_OVERHEAD
2026-06-25 17:30:42 +08:00
break
case 'tool-call' :
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
tokens += Math . ceil ( block . name . length / charsPerToken )
+ Math . ceil ( block . arguments . length / charsPerToken )
2026-06-25 17:30:42 +08:00
+ BLOCK_OVERHEAD
break
case 'tool-result' :
tokens += this . estimateContentTokens ( block . content ) + BLOCK_OVERHEAD
break
default :
// Unknown block types (merge-extensible ContentBlockMap):
// estimate conservatively via JSON stringify.
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
tokens += BLOCK_OVERHEAD + Math . ceil ( JSON . stringify ( block ) . length / charsPerToken )
2026-06-25 17:30:42 +08:00
}
}
return tokens
}
/ * *
* Estimate token count for a single session event . Returns 0 for non - message
* event types ( boundaries , chunks , usage , errors , compact markers ) .
Gate JSDoc completeness on every package export
New doc-sync gate verify-export-jsdoc walks every module-level exported
name under packages/*/*/src and requires description prose everywhere,
plus @param per parameter and @returns on non-void annotated returns for
function-like exports, public class methods, properties, and accessors.
The parsing + check helpers move out of gen-cordis-catalog.ts into a
shared scripts/jsdoc.ts so 'documented' means one thing on both gated
surfaces.
Deliberate exemptions (documented in the RFC): heritage-declared class
members (the seam declaration is the doc's one home — the one checker
query in an otherwise pure-AST walk), cordis plugin-protocol slots
(name/inject/reusable/Config/apply, top-level and static), constructors,
overload implementations, declare-module augmentation bodies, and
re-export statements (checked at the defining module).
The 203 under-documented exports the gate found at adoption are filled
in this change, so the gate lands green; generated catalogs/graphs are
regenerated for the shifted line pointers.
RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
2026-07-06 22:09:30 +08:00
*
* @param event - any session event ; only the message - bearing types carry
* content to count .
* @returns the estimated token count of the event ' s content , or 0 for a
* non - message event .
2026-06-25 17:30:42 +08:00
* /
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
}
}
Gate JSDoc completeness on every package export
New doc-sync gate verify-export-jsdoc walks every module-level exported
name under packages/*/*/src and requires description prose everywhere,
plus @param per parameter and @returns on non-void annotated returns for
function-like exports, public class methods, properties, and accessors.
The parsing + check helpers move out of gen-cordis-catalog.ts into a
shared scripts/jsdoc.ts so 'documented' means one thing on both gated
surfaces.
Deliberate exemptions (documented in the RFC): heritage-declared class
members (the seam declaration is the doc's one home — the one checker
query in an otherwise pure-AST walk), cordis plugin-protocol slots
(name/inject/reusable/Config/apply, top-level and static), constructors,
overload implementations, declare-module augmentation bodies, and
re-export statements (checked at the defining module).
The 203 under-documented exports the gate found at adoption are filled
in this change, so the gate lands green; generated catalogs/graphs are
regenerated for the shifted line pointers.
RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
2026-07-06 22:09:30 +08:00
/ * *
* Estimate total tokens across a list of messages plus optional system prompt .
*
* @param messages - the derived conversation messages ; each adds a fixed
* role - framing overhead on top of its content estimate .
* @param systemPrompt - counted at chars / ` charsPerToken ` when provided .
* @returns the estimated token footprint of the whole request .
* /
2026-06-25 17:30:42 +08:00
estimateTokens ( messages : readonly Message [ ] , systemPrompt? : string ) : number {
let total = 0
for ( const msg of messages ) {
total += this . estimateContentTokens ( msg . content )
total += ROLE_OVERHEAD
}
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
2026-07-04 17:37:23 +08:00
if ( systemPrompt ) total += Math . ceil ( systemPrompt . length / this . config . charsPerToken )
2026-06-25 17:30:42 +08:00
return total
}
/ * *
2026-07-13 23:27:00 +08:00
* Summarize through a direct one - shot ` ctx.llm.stream() ` call , not an agent
* step or ` agent/request ` dispatch . Failure finishes and truncated summaries
* reject ; the signal is forwarded and only text reaches the checkpoint .
Gate JSDoc completeness on every package export
New doc-sync gate verify-export-jsdoc walks every module-level exported
name under packages/*/*/src and requires description prose everywhere,
plus @param per parameter and @returns on non-void annotated returns for
function-like exports, public class methods, properties, and accessors.
The parsing + check helpers move out of gen-cordis-catalog.ts into a
shared scripts/jsdoc.ts so 'documented' means one thing on both gated
surfaces.
Deliberate exemptions (documented in the RFC): heritage-declared class
members (the seam declaration is the doc's one home — the one checker
query in an otherwise pure-AST walk), cordis plugin-protocol slots
(name/inject/reusable/Config/apply, top-level and static), constructors,
overload implementations, declare-module augmentation bodies, and
re-export statements (checked at the defining module).
The 203 under-documented exports the gate found at adoption are filled
in this change, so the gate lands green; generated catalogs/graphs are
regenerated for the shifted line pointers.
RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
2026-07-06 22:09:30 +08:00
*
* @param text - plain - text rendering of the conversation region to condense .
* @param agent - supplies the fallback model and the session id stamped on
* the call ; throws when neither it nor the config names a model .
* @param signal - optional abort signal , forwarded into the model call .
* @returns the text - only summary blocks plus the call envelope used
* ( ` model ` , and ` maxTokens ` when the summarizer has a cap ) .
2026-06-25 17:30:42 +08:00
* /
2026-07-06 03:21:29 +08:00
async summarize (
text : string , agent : Agent , signal? : AbortSignal ,
) : Promise < { summary : ContentBlock [ ] ; model : string ; maxTokens? : number } > {
2026-06-25 17:30:42 +08:00
const assembler = new BlockAssembler ( )
const options : GenerateOptions = {
2026-06-29 15:59:52 +08:00
model : this.config.summarizationModel || agent . options . model || '' ,
2026-06-25 17:30:42 +08:00
messages : [ {
role : 'user' ,
content : [ { type : 'text' , text : ` Summarize this conversation history: \ n \ n ${ text } \ n \ nSummary: ` } ] ,
} ] ,
system : SUMMARIZE_SYSTEM_PROMPT ,
2026-06-29 16:56:44 +08:00
maxTokens : this.config.maxTokens ,
2026-06-30 12:07:20 +08:00
sessionId : agent.session.id ,
2026-06-25 17:30:42 +08:00
}
// exactOptionalPropertyTypes: only set `signal` when present — assigning
// `undefined` to an optional `signal?: AbortSignal` is a type error.
if ( signal ) options . signal = signal
2026-07-06 02:16:11 +08:00
if ( ! options . model ) {
throw new Error ( 'no model available for summarization: set BasicCompactConfig.summarizationModel or AgentOptions.model' )
2026-06-29 15:59:52 +08:00
}
2026-07-06 02:16:11 +08:00
for await ( const chunk of this . ctx . llm . stream ( options ) ) {
2026-06-25 17:30:42 +08:00
assembler . push ( chunk )
}
const error = finishError ( assembler . finish )
if ( error ) throw error
2026-06-29 18:04:59 +08:00
const summary = this . _textOnly ( assembler . message ( ) . content )
2026-06-29 16:56:44 +08:00
if ( ! summary . some ( block = > block . type === 'text' && block . text . trim ( ) . length > 0 ) ) {
2026-06-29 18:04:59 +08:00
throw new Error ( 'summarization produced no text summary content' )
2026-06-29 16:56:44 +08:00
}
2026-07-06 03:55:30 +08:00
// config.maxTokens is required and validated positive, so this backend's
// envelope always carries the cap; the return type's optionality exists
// for overriding subclasses whose summarizer has none.
return { summary , model : options.model , maxTokens : this.config.maxTokens }
2026-06-25 17:30:42 +08:00
}
// ---- Core API (implements the abstract contract) ----
/ * *
2026-07-13 23:27:00 +08:00
* The sole pressure gate : count the next request ' s prefix , derived history ,
* and system prompt . Above threshold , retain a recent tool - balanced tail and
* compact the head , reconsolidating any prior automatic checkpoint . Returns
* ` null ` when no safe or necessary range exists .
2026-06-25 17:30:42 +08:00
* /
override async compactIfNeeded (
2026-06-29 15:59:52 +08:00
agent : Agent ,
fullSystemPrompt : string ,
2026-07-08 20:30:10 +08:00
sessionPrefix : readonly Message [ ] ,
2026-06-26 08:59:33 +08:00
signal : AbortSignal ,
2026-06-25 17:30:42 +08:00
) : Promise < CompactionResult | null > {
2026-06-29 15:59:52 +08:00
const session = agent . session
2026-06-25 17:30:42 +08:00
const threshold = Math . floor ( this . config . contextWindow * this . config . thresholdRatio )
2026-06-29 16:56:44 +08:00
let result : CompactionResult | null = null
for ( let attempt = 0 ; attempt <= this . config . compactionRetries ; attempt ++ ) {
2026-07-08 20:30:10 +08:00
const totalTokens = this . estimatePressure ( session , fullSystemPrompt , sessionPrefix )
2026-06-29 16:56:44 +08:00
if ( totalTokens < threshold ) return result
const range = this . _compactableRange ( session )
if ( range === null ) {
2026-06-29 18:04:59 +08:00
/* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */
2026-06-29 16:56:44 +08:00
if ( result === null ) return null
2026-06-29 18:04:59 +08:00
/* v8 ignore next -- paired with the ignored defensive branch above. */
2026-06-29 16:56:44 +08:00
break
}
2026-06-26 08:59:33 +08:00
2026-07-06 02:16:11 +08:00
result = await this . compactRegion ( session , range . start , range . end , agent , signal )
2026-06-25 17:30:42 +08:00
}
2026-07-08 20:30:10 +08:00
const totalTokens = this . estimatePressure ( session , fullSystemPrompt , sessionPrefix )
2026-06-29 16:56:44 +08:00
if ( totalTokens < threshold ) return result
2026-06-25 17:30:42 +08:00
2026-06-29 16:56:44 +08:00
throw new Error (
` compaction still above threshold after ${ this . config . compactionRetries + 1 } compaction attempts `
+ ` ( ${ totalTokens } estimated tokens >= threshold ${ threshold } ) ` ,
)
2026-06-25 17:30:42 +08:00
}
2026-07-08 19:32:42 +08:00
/ * *
2026-07-08 20:30:10 +08:00
* Estimated token pressure of the NEXT request : the session prefix
* ( ` EpochHeader.messagePrefix ` — request - only messages the loop sends in
* front of the derived history , composed before the pre - step seam and
* handed to the gate ) , the derived history , and the system prompt .
2026-07-08 19:32:42 +08:00
* @param session - the session whose next request is being estimated .
* @param fullSystemPrompt - the assembled system prompt ( counts toward pressure ) .
2026-07-08 20:30:10 +08:00
* @param sessionPrefix - the instance ' s composed session prefix ( counts toward pressure ) .
2026-07-08 19:32:42 +08:00
* @returns the estimated token total the next request will carry .
* /
2026-07-08 20:30:10 +08:00
estimatePressure ( session : Session , fullSystemPrompt : string , sessionPrefix : readonly Message [ ] ) : number {
2026-07-08 19:32:42 +08:00
return this . estimateTokens ( [ . . . sessionPrefix , . . . session . deriveMessages ( ) ] , fullSystemPrompt )
}
2026-06-25 17:30:42 +08:00
override async compactRegion (
session : Session ,
start : number ,
end : number ,
2026-06-29 15:59:52 +08:00
agent : Agent ,
2026-06-25 17:30:42 +08:00
signal? : AbortSignal ,
) : Promise < CompactionResult > {
2026-07-13 23:27:00 +08:00
// Resolve by surface position: a newer replacement seq may occupy an older slot.
2026-06-25 17:30:42 +08:00
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 ` )
}
2026-07-13 23:27:00 +08:00
// Both range edges must preserve assistant tool-call/result pairing.
2026-07-15 13:22:44 +08:00
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const startNode = nodes [ startIdx ] !
if ( ! toolPairingBalancedBefore ( session , startNode ) ) {
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001)
Codex round 1 CBR-001: a head-anchored compaction checkpoint was
mis-classified by the log-position step-alignment scan, so a second
auto-compaction over a checkpoint-headed surface silently failed.
Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a
`replace` op lands a checkpoint at a high log seq whose SURFACE position
is the head — its log neighbours (the open step's assistant/message) are
not its surface neighbours, so the forward scan wrongly reported mid-step.
Fix, per the agreed direction:
- Replace the two log-position predicates with one surface-anchored
helper `isToolPairingBalanced(nodes, events, beforeSeq)` in
`dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is
balanced when no unanswered tool-call precedes it on the surface; a
region is collapsible iff both edges are balanced cuts. The open-tail
and free-node cases fall out of the same counter. It also throws on a
corrupt surface (a tool/result with no matching call).
- Move compaction off the in-step seam to a new "pre-step" seam fired
after turn/start and before step/start, so a compaction's log-only
compact/* records and its replacement node land cleanly OUTSIDE any
step (the honest structure crash-safety relies on). Renamed the event
agent/pre-request → agent/pre-step and switched its dispatch from
parallel → serial (listeners mutate the surface as a side effect;
serial isolates them so concurrent appends can't interleave). Extended
the catalog generator to accept @mode serial.
Regression coverage: a real-loop test driving an auto-compaction asserts
the landed checkpoint is a balanced cut on both sides; unit tests pin the
checkpoint case, the mid-step injection case, multi-call steps, and the
corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
throw new Error ( ` compactRegion: start seq ${ start } is not a balanced boundary (would split a step's tool-call/result pair) ` )
2026-06-25 17:30:42 +08:00
}
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001)
Codex round 1 CBR-001: a head-anchored compaction checkpoint was
mis-classified by the log-position step-alignment scan, so a second
auto-compaction over a checkpoint-headed surface silently failed.
Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a
`replace` op lands a checkpoint at a high log seq whose SURFACE position
is the head — its log neighbours (the open step's assistant/message) are
not its surface neighbours, so the forward scan wrongly reported mid-step.
Fix, per the agreed direction:
- Replace the two log-position predicates with one surface-anchored
helper `isToolPairingBalanced(nodes, events, beforeSeq)` in
`dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is
balanced when no unanswered tool-call precedes it on the surface; a
region is collapsible iff both edges are balanced cuts. The open-tail
and free-node cases fall out of the same counter. It also throws on a
corrupt surface (a tool/result with no matching call).
- Move compaction off the in-step seam to a new "pre-step" seam fired
after turn/start and before step/start, so a compaction's log-only
compact/* records and its replacement node land cleanly OUTSIDE any
step (the honest structure crash-safety relies on). Renamed the event
agent/pre-request → agent/pre-step and switched its dispatch from
parallel → serial (listeners mutate the surface as a side effect;
serial isolates them so concurrent appends can't interleave). Extended
the catalog generator to accept @mode serial.
Regression coverage: a real-loop test driving an auto-compaction asserts
the landed checkpoint is a balanced cut on both sides; unit tests pin the
checkpoint case, the mid-step injection case, multi-call steps, and the
corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
2026-07-15 13:22:44 +08:00
const endNode = nodes [ endIdx ] !
if ( ! toolPairingBalancedAfter ( session , endNode ) ) {
fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001)
Codex round 1 CBR-001: a head-anchored compaction checkpoint was
mis-classified by the log-position step-alignment scan, so a second
auto-compaction over a checkpoint-headed surface silently failed.
Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a
`replace` op lands a checkpoint at a high log seq whose SURFACE position
is the head — its log neighbours (the open step's assistant/message) are
not its surface neighbours, so the forward scan wrongly reported mid-step.
Fix, per the agreed direction:
- Replace the two log-position predicates with one surface-anchored
helper `isToolPairingBalanced(nodes, events, beforeSeq)` in
`dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is
balanced when no unanswered tool-call precedes it on the surface; a
region is collapsible iff both edges are balanced cuts. The open-tail
and free-node cases fall out of the same counter. It also throws on a
corrupt surface (a tool/result with no matching call).
- Move compaction off the in-step seam to a new "pre-step" seam fired
after turn/start and before step/start, so a compaction's log-only
compact/* records and its replacement node land cleanly OUTSIDE any
step (the honest structure crash-safety relies on). Renamed the event
agent/pre-request → agent/pre-step and switched its dispatch from
parallel → serial (listeners mutate the surface as a side effect;
serial isolates them so concurrent appends can't interleave). Extended
the catalog generator to accept @mode serial.
Regression coverage: a real-loop test driving an auto-compaction asserts
the landed checkpoint is a balanced cut on both sides; unit tests pin the
checkpoint case, the mid-step injection case, multi-call steps, and the
corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
throw new Error ( ` compactRegion: end seq ${ end } is not a balanced boundary (would split a step, or the step is still open) ` )
2026-06-25 17:30:42 +08:00
}
if ( this . _isCompactionInProgress ( session ) ) {
throw new Error ( 'compaction already in progress' )
}
2026-07-12 03:36:43 +08:00
// 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.
2026-06-29 15:59:52 +08:00
const openTurn = this . _openTurn ( session )
if ( openTurn === null ) {
2026-06-25 17:30:42 +08:00
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 ---
2026-06-29 15:59:52 +08:00
const startEvent = session . append ( 'compact/start' , { turn : openTurn } )
2026-06-25 17:30:42 +08:00
try {
// --- Extract text and summarize ---
2026-07-08 20:45:05 -07:00
const text = renderTranscript ( session . events , shadowedSeqs )
2026-07-06 03:21:29 +08:00
const { summary , model , maxTokens } = await this . summarize ( text , agent , signal )
2026-06-25 17:30:42 +08:00
// 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 ] ! )
}
2026-06-30 10:56:34 +08:00
const framedSummary = this . _frameSummary ( summary )
const framedSummaryTokenCount = this . estimateContentTokens ( framedSummary )
if ( framedSummaryTokenCount >= shadowedTokenCount ) {
2026-06-29 16:56:44 +08:00
throw new Error (
2026-06-30 10:56:34 +08:00
` summary is not smaller than the shadowed content ( ${ framedSummaryTokenCount } estimated framed tokens >= ${ shadowedTokenCount } ) ` ,
2026-06-29 16:56:44 +08:00
)
}
2026-06-25 17:30:42 +08:00
// --- Provenance record (log-only) ---
const summaryEvent = session . append ( 'compact/summary' , {
summary ,
shadowedRange : { start , end } ,
shadowedSeqs ,
shadowedTokenCount ,
2026-07-06 03:21:29 +08:00
model ,
. . . maxTokens !== undefined ? { maxTokens } : { } ,
2026-06-25 17:30:42 +08:00
} )
2026-07-12 03:36:43 +08:00
// --- Surface replacement --- The user/message directly shadows all compacted surface
// nodes with a single replace op.
2026-06-25 17:30:42 +08:00
session . append ( 'user/message' , {
2026-06-30 10:56:34 +08:00
content : framedSummary ,
2026-06-25 17:30:42 +08:00
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.
2026-06-29 15:59:52 +08:00
const endEvent = session . append ( 'compact/end' , { turn : openTurn } )
2026-06-25 17:30:42 +08:00
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 )
2026-06-29 15:59:52 +08:00
session . append ( 'compact/end' , { turn : openTurn , error : msg } )
2026-06-25 17:30:42 +08:00
throw error
}
}
// ---- Internal helpers ----
/ * *
* 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 } ,
]
}
/ * *
2026-07-12 03:36:43 +08:00
* Whether a compaction is currently in progress for ` session ` — an unmatched ` compact/start `
* ( no later ` compact/end ` ) WITHIN the current turn .
2026-06-25 17:30:42 +08:00
* /
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
}
2026-06-29 16:56:44 +08:00
/** Resolve the next head-anchored compactable surface range, or `null`. */
private _compactableRange ( session : Session ) : { start : number ; end : number } | null {
const nodes = session . surface . nodes
if ( nodes . length === 0 ) return null
const events = session . events
const retainBudget = this . config . retainTokens
// Walk tail→head summing per-node token estimates. `keepFromIdx` is the
// index of the OLDEST node we retain verbatim; everything strictly older
// (`[0, keepFromIdx - 1]`) is the compactable range.
let accumulated = 0
let keepFromIdx = nodes . length // nothing retained yet
for ( let i = nodes . length - 1 ; i >= 0 ; i -- ) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const node = nodes [ i ] !
const event = events [ node . seq ]
/* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
if ( event ) accumulated += this . estimateEventTokens ( event )
keepFromIdx = i
if ( accumulated >= retainBudget ) break
}
// The whole surface fits the retain budget — nothing to compact.
if ( keepFromIdx === 0 ) return null
2026-07-12 03:36:43 +08:00
// Round the cutoff to a tool-pairing boundary: if the cut before `nodes[keepFromIdx]` is
// unbalanced (an unanswered tool-call sits before it — i.e. it is mid-step), extend the
// retained side head-ward until the cut is balanced, so the compacted range ends without
// splitting an assistant↔result pair.
2026-06-29 16:56:44 +08:00
while ( keepFromIdx > 0 ) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
2026-07-15 13:22:44 +08:00
if ( toolPairingBalancedBefore ( session , nodes [ keepFromIdx ] ! ) ) break
2026-06-29 16:56:44 +08:00
keepFromIdx -= 1
}
if ( keepFromIdx === 0 ) return null
// The compacted range is [head … keepFromIdx - 1], anchored at the head.
// 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 [ keepFromIdx - 1 ] ! . seq
return { start : firstSeq , end : cutoffSeq }
}
2026-07-12 03:36:43 +08:00
/** Keep only text; checkpoints cannot contain reasoning or orphan tool calls. */
2026-06-29 18:04:59 +08:00
private _textOnly ( blocks : readonly ContentBlock [ ] ) : ContentBlock [ ] {
return blocks . filter ( ( block ) : block is Extract < ContentBlock , { type : 'text' } > = > block . type === 'text' )
2026-06-29 16:56:44 +08:00
}
2026-06-25 17:30:42 +08:00
/ * *
* 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
}
}
export default BasicCompactService