Bläddra i källkod

fix(token-meter): bound projection state via logged shadow prices

The contextBreakdown and contextPressure units carried the full priced
surface, so each session's persisted projection checkpoint grew without
bound. A surface replacement is now priced by the shadow-price event
logged directly before it — compact/summary for compaction, the new
compact/prune from tool-result pruning (priced through the injected
token meter) — and the unit states shrink to a fixed handful of numbers.
Regenerate the persistence/cordis/module/config catalogs.
imccyu 1 månad sedan
förälder
incheckning
59df683ef1

+ 2 - 0
docs/config-catalog.md

@@ -405,6 +405,8 @@ Source: [`packages/compact/compact-basic/src/types.ts:38`](../packages/compact/c
 
 ## `@deepseek-ai/dsh-compact-tool-result-prune`
 
+Requires: `tokenMeter`
+
 ```ts config-catalog
 /** Character-budget policy for deterministic tool-result pruning. */
 export interface ToolResultPruneConfig {

+ 5 - 2
docs/cordis-catalog/services.md

@@ -2358,7 +2358,10 @@ pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null
 /**
  * Prune every over-budget tool result from one stable current-surface snapshot.
  * Each replacement preserves the complete event data except for `content`,
- * and points at the shadowed node for durable provenance and replay.
+ * points at the shadowed node for durable provenance and replay, and is
+ * immediately preceded by a `compact/prune` shadow-price event pricing the
+ * shadowed node through the injected token meter, so pure consumers can
+ * subtract it without per-node state.
  * @param session - session whose current surface is rewritten.
  * @returns landed replacements and aggregate Unicode-code-point savings.
  * @throws when the session rejects a replacement; replacements committed
@@ -2369,7 +2372,7 @@ pruneSession(session: Session): PruneResult
 
 Types: [ContentBlock](../core-data-structures/core.md) · [PruneResult](../core-data-structures/compaction.md) · [Session](../core-data-structures/session.md)
 
-Source: [`packages/compact/compact-tool-result-prune/src/index.ts:40`](../../packages/compact/compact-tool-result-prune/src/index.ts)
+Source: [`packages/compact/compact-tool-result-prune/src/index.ts:44`](../../packages/compact/compact-tool-result-prune/src/index.ts)
 
 ## `ctx.tools` — `ToolRegistry`
 

+ 16 - 13
docs/module-graph.md

@@ -428,9 +428,6 @@ flowchart TD
   pkg_compact --> pkg_invariants
   pkg_compact --> pkg_llm
   pkg_compact --> pkg_session
-  pkg_compact_tool_result_prune --> pkg_invariants
-  pkg_compact_tool_result_prune --> pkg_llm
-  pkg_compact_tool_result_prune --> pkg_session
   pkg_web_fetch_local --> pkg_invariants
   pkg_web_fetch_local --> pkg_timeout
   pkg_web_fetch_local --> pkg_web
@@ -490,6 +487,7 @@ flowchart TD
   pkg_llm_retry --> pkg_llm
   pkg_llm_retry --> pkg_session
   pkg_llm_retry --> pkg_timeout
+  pkg_token_meter --> pkg_compact
   pkg_token_meter --> pkg_invariants
   pkg_token_meter --> pkg_llm
   pkg_token_meter --> pkg_session
@@ -626,13 +624,11 @@ flowchart TD
   pkg_command_compact --> pkg_commands
   pkg_command_compact --> pkg_compact
   pkg_command_compact --> pkg_invariants
-  pkg_compact_basic --> pkg_agent
-  pkg_compact_basic --> pkg_compact
-  pkg_compact_basic --> pkg_compact_tool_result_prune
-  pkg_compact_basic --> pkg_invariants
-  pkg_compact_basic --> pkg_llm
-  pkg_compact_basic --> pkg_session
-  pkg_compact_basic --> pkg_token_meter
+  pkg_compact_tool_result_prune --> pkg_compact
+  pkg_compact_tool_result_prune --> pkg_invariants
+  pkg_compact_tool_result_prune --> pkg_llm
+  pkg_compact_tool_result_prune --> pkg_session
+  pkg_compact_tool_result_prune --> pkg_token_meter
   pkg_session_query --> pkg_brand
   pkg_session_query --> pkg_invariants
   pkg_session_query --> pkg_llm
@@ -728,6 +724,13 @@ flowchart TD
   pkg_tool_skill --> pkg_llm
   pkg_tool_skill --> pkg_skill
   pkg_tool_skill --> pkg_tools
+  pkg_compact_basic --> pkg_agent
+  pkg_compact_basic --> pkg_compact
+  pkg_compact_basic --> pkg_compact_tool_result_prune
+  pkg_compact_basic --> pkg_invariants
+  pkg_compact_basic --> pkg_llm
+  pkg_compact_basic --> pkg_session
+  pkg_compact_basic --> pkg_token_meter
   pkg_subagent --> pkg_agent
   pkg_subagent --> pkg_brand
   pkg_subagent --> pkg_invariants
@@ -1138,7 +1141,6 @@ flowchart TD
 | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
-| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
 | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
 | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
@@ -1155,7 +1157,7 @@ flowchart TD
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
 | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
-| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
+| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
 | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) |
 | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
@@ -1188,7 +1190,7 @@ flowchart TD
 | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
 | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
 | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) |
-| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
 | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
@@ -1204,6 +1206,7 @@ flowchart TD
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
 | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
+| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
 | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
 | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) |

+ 31 - 4
docs/persistence-catalog.md

@@ -236,7 +236,31 @@ Source: [`packages/ui/commands/src/index.ts:132`](../packages/ui/commands/src/in
 'compact/end': { turn: number | null; error?: string }
 ```
 
-Source: [`packages/compact/compact/src/types.ts:51`](../packages/compact/compact/src/types.ts)
+Source: [`packages/compact/compact/src/types.ts:54`](../packages/compact/compact/src/types.ts)
+
+#### `compact/prune` — log-only
+
+```ts persistence-catalog
+/**
+ * Shadow price of one model-free prune replacement — log-only, no
+ * surfaceOp. The shared shadow-price protocol: a surface `replace` event
+ * is priced by the metering event immediately before it (`compact/summary`
+ * for a summarizing compaction, this event for a prune), which states the
+ * heuristic token price of the exact replaced range so a pure consumer
+ * can subtract it without retaining per-node prices. The replacement MUST
+ * be appended synchronously right after this event.
+ */
+'compact/prune': {
+  /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */
+  shadowedRange: { start: number; end: number }
+  /** The seqs of all shadowed surface nodes, in surface order. */
+  shadowedSeqs: number[]
+  /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */
+  shadowedTokenCount: number
+}
+```
+
+Source: [`packages/compact/compact/src/types.ts:64`](../packages/compact/compact/src/types.ts)
 
 #### `compact/start` — log-only
 
@@ -257,8 +281,11 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact
 /**
  * Provenance record of a completed summarization — log-only, no surfaceOp.
  * The summary content is in `data.summary`; the actual surface replacement
- * is performed by a subsequent `user/message` event that shadows the
- * compacted range.
+ * is performed by the immediately following `user/message` event that
+ * shadows the compacted range. That adjacency is contractual — the
+ * shadowed pricing fields are the replacement's shadow price, so a
+ * consumer may pair a replacement with the metering event directly
+ * before it (`compact/prune` documents the shared protocol).
  */
 'compact/summary': {
   summary: ContentBlock[]
@@ -285,7 +312,7 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact
 
 Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
 
-Source: [`packages/compact/compact/src/types.ts:26`](../packages/compact/compact/src/types.ts)
+Source: [`packages/compact/compact/src/types.ts:29`](../packages/compact/compact/src/types.ts)
 
 ### `goal/*`
 

+ 4 - 0
packages/compact/compact-tool-result-prune/package.json

@@ -25,9 +25,11 @@
   ],
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-compact": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
+    "@deepseek-ai/dsh-token-meter": "^0.0.1",
     "cordis": "^4.0.0-rc.7"
   },
   "dependencies": {
@@ -36,9 +38,11 @@
   "devDependencies": {
     "@cordisjs/plugin-include": "workspace:^",
     "@cordisjs/plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-compact": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-token-meter": "workspace:^",
     "cordis": "^4.0.0-rc.7"
   }
 }

+ 20 - 1
packages/compact/compact-tool-result-prune/src/index.ts

@@ -9,6 +9,10 @@ import z from 'schemastery'
 import { freezeMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session'
+// Type-only: the `compact/*` SessionEventMap merges (the shadow-price event).
+import type {} from '@deepseek-ai/dsh-compact'
+// Type-only: the `ctx.tokenMeter` Context merge for the declared injection.
+import type {} from '@deepseek-ai/dsh-token-meter'
 import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
 import type {
   PrunedEntry,
@@ -38,6 +42,10 @@ interface SnapshotCandidate {
 
 /** Deterministic head/middle/tail pruning for current tool-result surface nodes. */
 export class ToolResultPruneService extends Service {
+  // The token meter prices each shadowed node for its logged shadow-price
+  // event, so pruning genuinely requires the pricing capability.
+  static inject = ['tokenMeter']
+
   static Config: z<ToolResultPruneConfig> = z.object({
     thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars),
     headChars: z.number().step(1).min(0).default(DEFAULTS.headChars),
@@ -116,7 +124,10 @@ export class ToolResultPruneService extends Service {
   /**
    * Prune every over-budget tool result from one stable current-surface snapshot.
    * Each replacement preserves the complete event data except for `content`,
-   * and points at the shadowed node for durable provenance and replay.
+   * points at the shadowed node for durable provenance and replay, and is
+   * immediately preceded by a `compact/prune` shadow-price event pricing the
+   * shadowed node through the injected token meter, so pure consumers can
+   * subtract it without per-node state.
    * @param session - session whose current surface is rewritten.
    * @returns landed replacements and aggregate Unicode-code-point savings.
    * @throws when the session rejects a replacement; replacements committed
@@ -145,6 +156,14 @@ export class ToolResultPruneService extends Service {
           content,
         }] as [typeof result],
       })
+      // Shadow-price protocol: the metering event and its replacement are
+      // appended synchronously adjacent, so pure consumers subtract the
+      // shadowed node's heuristic price without retaining per-node state.
+      session.append('compact/prune', {
+        shadowedRange: { start: seq, end: seq },
+        shadowedSeqs: [seq],
+        shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(event.data.message),
+      })
       const replacement = session.append('tool/result', {
         ...event.data,
         message,

+ 8 - 4
packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts

@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import Loader from '@cordisjs/plugin-loader'
 import Include from '@cordisjs/plugin-include'
+import TokenMeterService from '@deepseek-ai/dsh-token-meter'
 import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
 
 let root: string | undefined
@@ -23,6 +24,7 @@ describe('compact-tool-result-prune real Loader composition', () => {
     root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-'))
     const configPath = join(root, 'cordis.yml')
     await writeFile(configPath, [
+      "- name: '@deepseek-ai/dsh-token-meter'",
       "- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
       '  config:',
       '    thresholdChars: 100',
@@ -38,10 +40,9 @@ describe('compact-tool-result-prune real Loader composition', () => {
     context.loader.internal = {
       version: 'v2',
       async import(specifier: string) {
-        if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') {
-          throw new Error(`unexpected Loader import: ${specifier}`)
-        }
-        return ToolResultPruneService
+        if (specifier === '@deepseek-ai/dsh-token-meter') return TokenMeterService
+        if (specifier === '@deepseek-ai/dsh-compact-tool-result-prune') return ToolResultPruneService
+        throw new Error(`unexpected Loader import: ${specifier}`)
       },
     } as unknown as NonNullable<typeof context.loader.internal>
     await context.loader.create({
@@ -60,6 +61,9 @@ describe('compact-tool-result-prune real Loader composition', () => {
 
   it('rejects stale config after plugin schema normalization', async () => {
     context = new Context()
+    // Satisfy the declared injection first: config normalization runs in the
+    // service constructor, which a pending fiber never reaches.
+    await context.plugin(TokenMeterService)
     await expect(context.plugin(ToolResultPruneService, {
       maxChars: 100,
     } as never)).rejects.toThrow(/unknown key "maxChars"/)

+ 22 - 1
packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts

@@ -9,6 +9,7 @@ import SessionStore, {
 import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
 import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
 import InvariantService from '@deepseek-ai/dsh-invariants'
+import TokenMeterService from '@deepseek-ai/dsh-token-meter'
 import ToolResultPruneService, {
   codePointLength,
   DEFAULTS,
@@ -25,9 +26,16 @@ const SMALL: ToolResultPruneConfig = {
 }
 
 function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService {
-  return new ToolResultPruneService(new Context(), config)
+  const ctx = new Context()
+  // Service constructors self-register, so `ctx.tokenMeter` resolves for the
+  // shadow-price pricing without a full plugin boot.
+  void new TokenMeterService(ctx)
+  return new ToolResultPruneService(ctx, config)
 }
 
+/** Pricing oracle mirroring the service's estimator for expectations. */
+const METER = new TokenMeterService(new Context())
+
 function appendToolStep(
   session: Session,
   turn: number,
@@ -203,6 +211,18 @@ describe('ToolResultPruneService session transaction', () => {
       sourceEventSeqs: [originalSeq],
     })
     expect(session.surface.nodes).not.toContain(originalSeq)
+
+    // Shadow-price protocol: the metering event sits directly before the
+    // replacement and prices the shadowed node with the shared estimator.
+    if (original.type !== 'tool/result') throw new Error('original is not a tool/result')
+    expect(session.events[entry.replacementSeq - 1]).toMatchObject({
+      type: 'compact/prune',
+      data: {
+        shadowedRange: { start: originalSeq, end: originalSeq },
+        shadowedSeqs: [originalSeq],
+        shadowedTokenCount: METER.estimateMessage(original.data.message),
+      },
+    })
   })
 
   it('prunes multiple results, skips short ones, and converges in one pass', () => {
@@ -240,6 +260,7 @@ describe('ToolResultPruneService session transaction', () => {
     await ctx.plugin(SessionStore)
     await ctx.plugin(InvariantService)
     await ctx.plugin(SessionInvariant)
+    await ctx.plugin(TokenMeterService)
     const prune = new ToolResultPruneService(ctx, SMALL)
     const session = ctx.sessions.create(SessionId('invariants'))
     appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])

+ 2 - 0
packages/compact/compact-tool-result-prune/tsconfig.json

@@ -10,7 +10,9 @@
     { "path": "../../../vendor/cordis" },
     { "path": "../../../vendor/schemastery" },
     { "path": "../../llm/llm" },
+    { "path": "../../llm/token-meter" },
     { "path": "../../core/session" },
+    { "path": "../compact" },
     { "path": "../../support/invariants" }
   ]
 }

+ 22 - 2
packages/compact/compact/src/types.ts

@@ -20,8 +20,11 @@ declare module '@deepseek-ai/dsh-session' {
     /**
      * Provenance record of a completed summarization — log-only, no surfaceOp.
      * The summary content is in `data.summary`; the actual surface replacement
-     * is performed by a subsequent `user/message` event that shadows the
-     * compacted range.
+     * is performed by the immediately following `user/message` event that
+     * shadows the compacted range. That adjacency is contractual — the
+     * shadowed pricing fields are the replacement's shadow price, so a
+     * consumer may pair a replacement with the metering event directly
+     * before it (`compact/prune` documents the shared protocol).
      */
     'compact/summary': {
       summary: ContentBlock[]
@@ -49,6 +52,23 @@ declare module '@deepseek-ai/dsh-session' {
      * matches `compact/start`; `error` records an unsuccessful attempt.
      */
     'compact/end': { turn: number | null; error?: string }
+    /**
+     * Shadow price of one model-free prune replacement — log-only, no
+     * surfaceOp. The shared shadow-price protocol: a surface `replace` event
+     * is priced by the metering event immediately before it (`compact/summary`
+     * for a summarizing compaction, this event for a prune), which states the
+     * heuristic token price of the exact replaced range so a pure consumer
+     * can subtract it without retaining per-node prices. The replacement MUST
+     * be appended synchronously right after this event.
+     */
+    'compact/prune': {
+      /** The replaced range's first and last surface-node seqs (a surface-position span, like {@link CompactionResult.shadowedRange}). */
+      shadowedRange: { start: number; end: number }
+      /** The seqs of all shadowed surface nodes, in surface order. */
+      shadowedSeqs: number[]
+      /** Heuristic price of the shadowed content under the token-meter's fixed estimator. */
+      shadowedTokenCount: number
+    }
   }
 }
 

+ 1 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -1058,7 +1058,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'pruneSession(session: Session): PruneResult',
-        jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * and points at the shadowed node for durable provenance and replay.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */',
+        jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * points at the shadowed node for durable provenance and replay, and is\n * immediately preceded by a `compact/prune` shadow-price event pricing the\n * shadowed node through the injected token meter, so pure consumers can\n * subtract it without per-node state.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */',
       },
     ],
   },

+ 2 - 0
packages/llm/token-meter/package.json

@@ -30,6 +30,7 @@
   ],
   "license": "BSD-3-Clause",
   "peerDependencies": {
+    "@deepseek-ai/dsh-compact": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-session": "^0.0.1",
@@ -41,6 +42,7 @@
     "zod": "^4.4.3"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-compact": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",

+ 25 - 18
packages/llm/token-meter/src/breakdown-projection.ts

@@ -6,11 +6,11 @@
  */
 
 import { z } from 'zod'
-import { canonicalHeader, isSurfaceEvent } from '@deepseek-ai/dsh-session'
+import { canonicalHeader } from '@deepseek-ai/dsh-session'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
-import type { TokenSurfaceNode } from './types.ts'
 import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts'
-import { foldSurfaceTokens } from './surface-fold.ts'
+import { foldSurfaceProjection } from './surface-projection.ts'
+import type { ShadowPriceClaim } from './surface-projection.ts'
 // Import for the `contextBreakdown` SessionProjectionMap key merge.
 import type {} from './projection.ts'
 
@@ -18,8 +18,8 @@ interface ContextBreakdownState {
   systemTokens: number
   toolsTokens: number
   messageTokens: number
-  /** Priced surface nodes (plain JSON for the persisted projection cache). */
-  surface: TokenSurfaceNode[]
+  /** Shadow price armed by the immediately preceding metering event. */
+  claim?: ShadowPriceClaim
 }
 
 const breakdownSchema = z.object({
@@ -32,31 +32,38 @@ const breakdownSchema = z.object({
  * Token-meter's context-composition projection unit.
  *
  * Envelope figures are last-wins per `request/header`; the message figure
- * rides {@link foldSurfaceTokens} — the same fold the measurement service
- * replays — so it equals `measure().surfaceTokens` at every event boundary and
- * compaction shrinks it the way it shrinks the next request.
+ * rides {@link foldSurfaceProjection} — the same O(1) fold the occupancy
+ * projection uses — so it equals `measure().surfaceTokens` at every event
+ * boundary and compaction shrinks it by its logged shadow price, the way it
+ * shrinks the next request. The state is a fixed handful of numbers, so the
+ * persisted checkpoint stays O(1) over the session's life.
  */
 export const contextBreakdownProjectionDefinition:
 ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = {
   key: 'contextBreakdown',
   schema: breakdownSchema,
-  init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0, surface: [] }),
+  init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }),
   apply: (state, event) => {
+    const fold = foldSurfaceProjection(state.claim, event)
+    let systemTokens = state.systemTokens
+    let toolsTokens = state.toolsTokens
     if (event.type === 'request/header') {
       const header = canonicalHeader(event.data.header)
-      const systemTokens = estimateSystemTokens(header)
-      const toolsTokens = estimateToolsTokens(header)
-      if (systemTokens === state.systemTokens && toolsTokens === state.toolsTokens) return state
-      return { ...state, systemTokens, toolsTokens }
+      systemTokens = estimateSystemTokens(header)
+      toolsTokens = estimateToolsTokens(header)
     }
-    if (!isSurfaceEvent(event)) return state
-    const fold = foldSurfaceTokens(state.surface, event)
+    if (systemTokens === state.systemTokens
+      && toolsTokens === state.toolsTokens
+      && fold.deltaTokens === 0
+      && fold.claim === undefined
+      && state.claim === undefined) return state
     return {
-      ...state,
+      systemTokens,
+      toolsTokens,
       messageTokens: state.messageTokens + fold.deltaTokens,
-      surface: fold.nodes,
+      ...fold.claim === undefined ? {} : { claim: fold.claim },
     }
   },
   view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }),
-  stateVersion: 1,
+  stateVersion: 2,
 }

+ 5 - 3
packages/llm/token-meter/src/invariant.ts

@@ -20,9 +20,11 @@ export const inject = ['invariants']
  * three projections do expose observation streams, but their schemas fix the
  * JSON payloads; the usage folds replace same-step samples, so totals need not
  * be monotone when a final sample corrects an earlier chunk, and the
- * composition fold shares `surface-fold.ts` with the measurement service,
- * which makes its message figure equal `measure().surfaceTokens` by
- * construction rather than by a relation worth observing at runtime.
+ * composition fold prices through the same `estimate.ts` heuristic as the
+ * measurement service and subtracts producer-logged shadow prices derived
+ * from that service's own nodes, which makes its message figure equal
+ * `measure().surfaceTokens` by construction rather than by a relation worth
+ * observing at runtime.
  */
 const install: InvariantInstaller = () => {}
 

+ 7 - 6
packages/llm/token-meter/src/surface-fold.ts

@@ -1,10 +1,11 @@
 /**
- * The one positional surface fold, shared by the measurement service's replay
- * state and the pure `contextBreakdown` projection. Both answer "what does the
- * current model-visible conversation cost", so they MUST price and place every
- * node identically: a private copy in either owner would let the panel's
- * message figure drift away from `measure().surfaceTokens` with both sides
- * still passing their own tests.
+ * The measurement service's positional surface fold: the per-node priced
+ * surface `measure()` serves and compaction plans against. The projection
+ * units deliberately do NOT share this fold — their state must stay O(1)
+ * for the persisted checkpoint, so they ride `surface-projection.ts`'s
+ * shadow-price protocol instead. The two stay in agreement by construction:
+ * both price through `estimate.ts`, and every logged shadow price is derived
+ * from THIS fold's nodes by the replace producer.
  *
  * @module @deepseek-ai/dsh-token-meter/surface-fold
  */

+ 84 - 0
packages/llm/token-meter/src/surface-projection.ts

@@ -0,0 +1,84 @@
+/**
+ * The O(1) surface-token fold shared by the token-meter projection units.
+ *
+ * A projection state must stay bounded — the persisted projection cache
+ * checkpoints every unit's whole state, so carrying the priced surface
+ * (one node per model-visible message) would grow a checkpoint without
+ * bound over the session's life. Instead, replacements ride the compact
+ * seam's shadow-price protocol: the metering event immediately before a
+ * surface `replace` (`compact/summary` or `compact/prune`) states the
+ * heuristic price of the exact replaced range, so the fold keeps a running
+ * total plus at most one pending claim and never retains per-node prices.
+ * The counts are exact by construction: producers derive them from the same
+ * fixed estimator this module prices appends with.
+ *
+ * @module @deepseek-ai/dsh-token-meter/surface-projection
+ */
+
+import { deriveEventMessage, isSurfaceEvent } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+// Type-only: the `compact/*` SessionEventMap merges (shadow-price events).
+import type {} from '@deepseek-ai/dsh-compact'
+import { estimateMessage } from './estimate.ts'
+
+/**
+ * One armed shadow price: the heuristic tokens of the surface range the
+ * IMMEDIATELY following event replaces. Plain JSON — it is part of the
+ * persisted unit state while armed.
+ */
+export interface ShadowPriceClaim {
+  /** Declared inclusive first surface-node seq of the priced range. */
+  start: number
+  /** Declared inclusive last surface-node seq of the priced range. */
+  end: number
+  /** Heuristic tokens of the priced range under the fixed estimator. */
+  tokens: number
+}
+
+/** One event's effect on a running surface-token total. */
+export interface SurfaceTokensFold {
+  /** Signed change in the surface total; 0 for events off the surface. */
+  readonly deltaTokens: number
+  /** Claim to carry into the next event; undefined when none survives. */
+  readonly claim: ShadowPriceClaim | undefined
+}
+
+/**
+ * Fold one committed event onto a running surface-token total.
+ *
+ * A shadow-price event arms a claim; any other event expires it, and a
+ * surface `replace` must consume a claim naming its exact range — the
+ * producers append the metering event and the replacement synchronously
+ * adjacent, so a surviving claim always prices the very next event.
+ * @param claim - the claim armed by the immediately preceding event, if any.
+ * @param event - the next committed session event.
+ * @returns the signed token delta and the claim state after this event.
+ * @throws when a replacement arrives without a claim for its exact range —
+ *   every in-repo replace producer meters its replacement, so an unpriced
+ *   replacement is a shadow-price contract violation and must fail loud
+ *   rather than let the total drift.
+ */
+export function foldSurfaceProjection(
+  claim: ShadowPriceClaim | undefined,
+  event: SessionEvent,
+): SurfaceTokensFold {
+  if (event.type === 'compact/summary' || event.type === 'compact/prune') {
+    const { shadowedRange, shadowedTokenCount } = event.data
+    return {
+      deltaTokens: 0,
+      claim: { start: shadowedRange.start, end: shadowedRange.end, tokens: shadowedTokenCount },
+    }
+  }
+  if (!isSurfaceEvent(event)) return { deltaTokens: 0, claim: undefined }
+  const message = deriveEventMessage(event)
+  const tokens = message === null ? 0 : estimateMessage(message)
+  const op = event.surfaceOp
+  if (op === 'append') return { deltaTokens: tokens, claim: undefined }
+  if (claim === undefined || claim.start !== op.start || claim.end !== op.end) {
+    throw new Error(
+      `token surface: replace at seq ${event.seq} over range ${op.start}-${op.end} has no adjacent shadow price`
+      + (claim === undefined ? '' : ` (armed claim covers ${claim.start}-${claim.end})`),
+    )
+  }
+  return { deltaTokens: tokens - claim.tokens, claim: undefined }
+}

+ 35 - 20
packages/llm/token-meter/src/usage-projection.ts

@@ -4,12 +4,11 @@
 
 import { z } from 'zod'
 import type { TokenUsage } from '@deepseek-ai/dsh-llm'
-import { isSurfaceEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
 import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
-import type { TokenSurfaceNode } from './types.ts'
-import { foldSurfaceTokens } from './surface-fold.ts'
+import { foldSurfaceProjection } from './surface-projection.ts'
+import type { ShadowPriceClaim } from './surface-projection.ts'
 
 interface UsageSample {
   turn: number
@@ -82,19 +81,25 @@ const usageOf = (event: SessionEvent): TokenUsage | undefined =>
 
 /**
  * Context-occupancy state: the two independent last-wins records plus the
- * priced surface needed to carry the newest sample forward.
+ * O(1) running surface total needed to carry the newest sample forward.
  */
 interface ContextPressureState {
   contextWindow?: number
   pressureTokens?: number
-  /** Priced surface, folded identically to the measurement service's. */
-  surface: TokenSurfaceNode[]
-  /** Summed heuristic tokens over {@link surface}. */
+  /** Running heuristic total over the current surface ({@link foldSurfaceProjection}). */
   surfaceTokens: number
   /** {@link surfaceTokens} at the newest usage sample; absent until one lands. */
   sampledSurfaceTokens?: number
+  /** Shadow price armed by the immediately preceding metering event. */
+  claim?: ShadowPriceClaim
 }
 
+/** Whether two optional shadow-price claims price the same range identically. */
+const claimEquals = (left: ShadowPriceClaim | undefined, right: ShadowPriceClaim | undefined): boolean =>
+  left === right
+  || (left !== undefined && right !== undefined
+    && left.start === right.start && left.end === right.end && left.tokens === right.tokens)
+
 /**
  * Token-meter's session projection unit.
  *
@@ -152,26 +157,33 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = {
  * `pressureTokens` is prompt-side only, so it holds still while a turn streams
  * and steps forward once the next request reports its usage. Because nothing
  * but a request reports usage, it also cannot see a compaction: the fold
- * therefore carries the priced surface alongside it and publishes
+ * therefore carries a running surface total alongside it and publishes
  * `projectedTokens` — the sample plus the surface's signed movement since it
  * was taken — so occupancy answers for the next request rather than the last
- * one. A usage sample is stamped BEFORE the same event joins the surface, so
- * an `assistant/message` anchors against the surface its own request saw.
+ * one. The total rides {@link foldSurfaceProjection}, so the state stays O(1)
+ * and a replacement shrinks it by its logged shadow price. A usage sample is
+ * stamped BEFORE the same event joins the surface, so an `assistant/message`
+ * anchors against the surface its own request saw.
  */
 export const contextPressureProjectionDefinition:
 ProjectionDefinition<'contextPressure', ContextPressureState> = {
   key: 'contextPressure',
   schema: pressureSchema,
-  init: () => ({ surface: [], surfaceTokens: 0 }),
+  init: () => ({ surfaceTokens: 0 }),
   apply: (state, event) => {
+    const fold = foldSurfaceProjection(state.claim, event)
+    let next = state
     if (event.type === 'request/context') {
       const contextWindow = event.data.contextWindow
-      if (contextWindow === state.contextWindow) return state
-      if (contextWindow !== undefined) return { ...state, contextWindow }
-      const { contextWindow: _removed, ...withoutContextWindow } = state
-      return withoutContextWindow
+      if (contextWindow !== state.contextWindow) {
+        if (contextWindow !== undefined) {
+          next = { ...next, contextWindow }
+        } else {
+          const { contextWindow: _removed, ...withoutContextWindow } = next
+          next = withoutContextWindow
+        }
+      }
     }
-    let next = state
     const usage = usageOf(event)
     if (usage !== undefined) {
       const pressureTokens = pressureFrom(usage)
@@ -179,9 +191,12 @@ ProjectionDefinition<'contextPressure', ContextPressureState> = {
         next = { ...next, pressureTokens, sampledSurfaceTokens: next.surfaceTokens }
       }
     }
-    if (!isSurfaceEvent(event)) return next
-    const fold = foldSurfaceTokens(next.surface, event)
-    return { ...next, surface: fold.nodes, surfaceTokens: next.surfaceTokens + fold.deltaTokens }
+    if (fold.deltaTokens !== 0) {
+      next = { ...next, surfaceTokens: next.surfaceTokens + fold.deltaTokens }
+    }
+    if (claimEquals(state.claim, fold.claim)) return next
+    const { claim: _expired, ...withoutClaim } = next
+    return fold.claim === undefined ? withoutClaim : { ...withoutClaim, claim: fold.claim }
   },
   view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({
     ...contextWindow === undefined ? {} : { contextWindow },
@@ -190,5 +205,5 @@ ProjectionDefinition<'contextPressure', ContextPressureState> = {
       ? {}
       : { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) },
   }),
-  stateVersion: 3,
+  stateVersion: 4,
 }

+ 76 - 5
packages/llm/token-meter/tests/context-breakdown-projection.spec.ts

@@ -48,6 +48,26 @@ function appendUser(session: Session, text: string): number {
   }), { surfaceOp: 'append' }).seq
 }
 
+/**
+ * Meter one upcoming replacement the way compact-basic does: price the
+ * replaced span from the measurement service's own nodes and log the
+ * shadow-price event directly before the replace.
+ */
+function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void {
+  const nodes = ctx.tokenMeter.measure(session).nodes
+  const startIdx = nodes.findIndex(node => node.seq === start)
+  const endIdx = nodes.findIndex(node => node.seq === end)
+  const shadowed = nodes.slice(startIdx, endIdx + 1)
+  session.append('compact/summary', {
+    summary: [{ type: 'text', text: 'summary' }],
+    shadowedRange: { start, end },
+    shadowedSeqs: shadowed.map(node => node.seq),
+    shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0),
+    provider: 'mock',
+    model: 'mock',
+  })
+}
+
 describe('contextBreakdown session projection', () => {
   it('serves zeros for an empty log', async () => {
     const { ctx, session } = await harness()
@@ -100,7 +120,7 @@ describe('contextBreakdown session projection', () => {
     expect(projected(ctx, session).messageTokens).toBe(9)
   })
 
-  it('shrinks the message figure when a replacement compacts the surface', async () => {
+  it('shrinks the message figure when a metered replacement compacts the surface', async () => {
     const { ctx, session } = await harness()
     const first = appendUser(session, 'before compaction, a longer message')
     const second = appendUser(session, 'and a second entry')
@@ -108,6 +128,7 @@ describe('contextBreakdown session projection', () => {
       content: [{ type: 'text', text: 'summary' }],
       source: { kind: 'plugin', plugin: 'test' },
     })
+    appendSummaryMeter(ctx, session, first, second)
     session.append('user/message', summary, {
       surfaceOp: { op: 'replace', start: first, end: second },
       sourceEventSeqs: [first, second],
@@ -146,6 +167,9 @@ describe('contextBreakdown session projection', () => {
     const grown = agree()
     expect(grown).toBeGreaterThan(0)
 
+    appendSummaryMeter(ctx, session, question, answer)
+    // The armed shadow price must not move the published figure by itself.
+    expect(agree()).toBe(grown)
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'summary' }],
       source: { kind: 'plugin', plugin: 'test' },
@@ -156,7 +180,7 @@ describe('contextBreakdown session projection', () => {
     expect(agree()).toBeLessThan(grown)
   })
 
-  it('fails loud on a replace range absent from the folded surface', () => {
+  it('fails loud on a replacement without an adjacent matching shadow price', () => {
     const definition = contextBreakdownProjectionDefinition
     const replace = (start: number, end: number): SessionEvent => ({
       type: 'user/message',
@@ -173,12 +197,59 @@ describe('contextBreakdown session projection', () => {
       data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
       surfaceOp: 'append',
     } as unknown as SessionEvent)
+    const meter = (start: number, end: number, seq: number): SessionEvent => ({
+      type: 'compact/prune',
+      seq,
+      time: 0,
+      data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 },
+    } as unknown as SessionEvent)
     let state = definition.init()
     state = definition.apply(state, append(1))
     state = definition.apply(state, append(3))
-    expect(() => definition.apply(state, replace(7, 3))).toThrow('invalid current range')
-    expect(() => definition.apply(state, replace(1, 7))).toThrow('invalid current range')
-    expect(() => definition.apply(state, replace(3, 1))).toThrow('invalid current range')
+    // No metering event at all.
+    expect(() => definition.apply(state, replace(1, 3))).toThrow('no adjacent shadow price')
+    // A claim for a different range does not price this replacement.
+    const mismatched = definition.apply(state, meter(1, 1, 8))
+    expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
+    // A claim expires after one intervening event instead of lingering.
+    let expired = definition.apply(state, meter(1, 3, 8))
+    expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
+    expect(() => definition.apply(expired, replace(1, 3))).toThrow('no adjacent shadow price')
+    // The armed claim prices exactly the next event's matching replacement.
+    const armed = definition.apply(state, meter(1, 3, 8))
+    expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens)
+      .toBe(definition.view(state).messageTokens - 5 + estimateMessage(
+        createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
+      ))
+  })
+
+  it('keeps the persisted checkpoint O(1) as the surface grows and compacts', async () => {
+    const { ctx, session } = await harness()
+    const first = appendUser(session, 'the first of many messages')
+    for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`)
+    const last = appendUser(session, 'the last message before compaction')
+    const stateKeys = (): string[] => {
+      const row = ctx.sessionProjections.checkpoint(session)['contextBreakdown']
+      if (row === undefined) throw new Error('contextBreakdown checkpoint row is missing')
+      return Object.keys(row.val as Record<string, unknown>).sort()
+    }
+    // Growth adds no per-node bookkeeping to the durable state.
+    expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens'])
+    const shadowed = session.surface.nodes.slice(
+      session.surface.nodes.indexOf(first),
+      session.surface.nodes.indexOf(last) + 1,
+    )
+    appendSummaryMeter(ctx, session, first, last)
+    session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'summary' }],
+      source: { kind: 'plugin', plugin: 'test' },
+    }), {
+      surfaceOp: { op: 'replace', start: first, end: last },
+      sourceEventSeqs: [...shadowed],
+    })
+    expect(stateKeys()).toEqual(['messageTokens', 'systemTokens', 'toolsTokens'])
+    expect(projected(ctx, session).messageTokens)
+      .toBe(ctx.tokenMeter.measure(session).surfaceTokens)
   })
 
   it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => {

+ 24 - 1
packages/llm/token-meter/tests/token-usage-projection.spec.ts

@@ -70,6 +70,26 @@ const projected = (ctx: Context, session: Session): TokenUsageProjection => {
   return value
 }
 
+/**
+ * Meter one upcoming replacement the way compact-basic does: price the
+ * replaced span from the measurement service's own nodes and log the
+ * shadow-price event directly before the replace.
+ */
+function appendSummaryMeter(ctx: Context, session: Session, start: number, end: number): void {
+  const nodes = ctx.tokenMeter.measure(session).nodes
+  const startIdx = nodes.findIndex(node => node.seq === start)
+  const endIdx = nodes.findIndex(node => node.seq === end)
+  const shadowed = nodes.slice(startIdx, endIdx + 1)
+  session.append('compact/summary', {
+    summary: [{ type: 'text', text: 'summary' }],
+    shadowedRange: { start, end },
+    shadowedSeqs: shadowed.map(node => node.seq),
+    shadowedTokenCount: shadowed.reduce((total, node) => total + node.tokens, 0),
+    provider: 'mock',
+    model: 'mock',
+  })
+}
+
 describe('tokenUsage session projection', () => {
   it('serves zero buckets for an empty log', async () => {
     const { ctx, session } = await harness()
@@ -184,6 +204,7 @@ describe('tokenUsage session projection', () => {
       content: [{ type: 'text', text: 'before compaction' }],
       source: { kind: 'user' },
     }), { surfaceOp: 'append' })
+    appendSummaryMeter(ctx, session, before.seq, before.seq)
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'compacted' }],
       source: { kind: 'plugin', plugin: 'test' },
@@ -351,7 +372,7 @@ describe('contextPressure session projection', () => {
     const checkpoint = JSON.parse(JSON.stringify(
       ctx.sessionProjections.checkpoint(session),
     )) as ReturnType<typeof ctx.sessionProjections.checkpoint>
-    expect(checkpoint.contextPressure?.ver).toBe(3)
+    expect(checkpoint.contextPressure?.ver).toBe(4)
 
     await meterFiber.dispose()
     expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure')
@@ -385,6 +406,7 @@ describe('contextPressure session projection', () => {
 
     // Compaction reports no usage of its own, so `pressureTokens` cannot move;
     // the projected figure must shrink anyway — the defect this field fixes.
+    appendSummaryMeter(ctx, session, question, grown)
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'summary' }],
       source: { kind: 'plugin', plugin: 'test' },
@@ -406,6 +428,7 @@ describe('contextPressure session projection', () => {
     // shadowing that span subtracts more than the sample holds.
     appendAssistant(session, 'ok', { inputTokens: 3, outputTokens: 1 }, 1, 1)
     session.append('step/end', { turn: 1, step: 1 })
+    appendSummaryMeter(ctx, session, question, question)
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: '.' }],
       source: { kind: 'plugin', plugin: 'test' },

+ 3 - 0
packages/llm/token-meter/tsconfig.json

@@ -23,6 +23,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../compact/compact"
+    },
     {
       "path": "../../session-projection/session-projection"
     },

+ 9 - 0
pnpm-lock.yaml

@@ -2363,6 +2363,9 @@ importers:
       '@cordisjs/plugin-loader':
         specifier: workspace:^
         version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-compact':
+        specifier: workspace:^
+        version: link:../compact
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
@@ -2372,6 +2375,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-token-meter':
+        specifier: workspace:^
+        version: link:../../llm/token-meter
       cordis:
         specifier: ^4.0.0-rc.7
         version: link:../../../vendor/cordis
@@ -3857,6 +3863,9 @@ importers:
         specifier: ^4.4.3
         version: 4.4.3
     devDependencies:
+      '@deepseek-ai/dsh-compact':
+        specifier: workspace:^
+        version: link:../../compact/compact
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants