浏览代码

feat(compaction): record image offload decisions without message replacement

creatixchu 3 周之前
父节点
当前提交
b5e7fca4a5
共有 83 个文件被更改,包括 1251 次插入 和 447 次删除
  1. 6 0
      .agents/notes/archived/architecture/2026-09-02-durable-image-offload.i18n.yaml
  2. 1 0
      .agents/notes/archived/architecture/2026-09-02-durable-image-offload.md
  3. 1 0
      .agents/notes/archived/architecture/2026-09-02-durable-image-offload.zh.md
  4. 3 0
      .agents/notes/archived/manifest.json
  5. 3 3
      .agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml
  6. 47 0
      .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
  7. 47 0
      .agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md
  8. 2 2
      .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml
  9. 2 2
      .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
  10. 2 2
      .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md
  11. 2 2
      docs/architecture.i18n.yaml
  12. 1 1
      docs/architecture.md
  13. 1 1
      docs/architecture.zh.md
  14. 2 2
      docs/config-catalog.i18n.yaml
  15. 2 2
      docs/config-catalog.md
  16. 2 2
      docs/config-catalog.zh.md
  17. 2 2
      docs/module-graph.i18n.yaml
  18. 4 6
      docs/module-graph.md
  19. 4 6
      docs/module-graph.zh.md
  20. 2 2
      docs/persistence-catalog.i18n.yaml
  21. 34 14
      docs/persistence-catalog.md
  22. 34 14
      docs/persistence-catalog.zh.md
  23. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  24. 3 4
      docs/subsystems/llm-streaming.md
  25. 3 4
      docs/subsystems/llm-streaming.zh.md
  26. 2 2
      docs/subsystems/session.i18n.yaml
  27. 33 8
      docs/subsystems/session.md
  28. 33 8
      docs/subsystems/session.zh.md
  29. 22 0
      packages/compaction/compaction-basic/tests/manual-compaction.spec.ts
  30. 2 2
      packages/compaction/compaction-image-offload/README.i18n.yaml
  31. 11 11
      packages/compaction/compaction-image-offload/README.md
  32. 11 11
      packages/compaction/compaction-image-offload/README.zh.md
  33. 2 6
      packages/compaction/compaction-image-offload/package.json
  34. 36 89
      packages/compaction/compaction-image-offload/src/image-offload.ts
  35. 6 8
      packages/compaction/compaction-image-offload/src/index.ts
  36. 60 35
      packages/compaction/compaction-image-offload/tests/image-offload.spec.ts
  37. 1 3
      packages/compaction/compaction-image-offload/tsconfig.json
  38. 2 2
      packages/compaction/compaction-tool-result-pruner/README.i18n.yaml
  39. 1 1
      packages/compaction/compaction-tool-result-pruner/README.md
  40. 1 1
      packages/compaction/compaction-tool-result-pruner/README.zh.md
  41. 4 3
      packages/compaction/compaction-tool-result-pruner/src/index.ts
  42. 19 0
      packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts
  43. 2 2
      packages/core/agent-loop/README.i18n.yaml
  44. 1 1
      packages/core/agent-loop/README.md
  45. 1 1
      packages/core/agent-loop/README.zh.md
  46. 3 3
      packages/core/agent-loop/src/agent.ts
  47. 2 2
      packages/core/session/README.i18n.yaml
  48. 6 4
      packages/core/session/README.md
  49. 6 4
      packages/core/session/README.zh.md
  50. 39 0
      packages/core/session/src/image-offload.ts
  51. 12 12
      packages/core/session/src/index.ts
  52. 1 0
      packages/core/session/src/known-event-types.ts
  53. 82 10
      packages/core/session/src/surface.ts
  54. 17 0
      packages/core/session/src/types.ts
  55. 136 0
      packages/core/session/tests/image-offload.spec.ts
  56. 6 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  57. 2 2
      packages/llm/llm-deepseek/README.i18n.yaml
  58. 1 1
      packages/llm/llm-deepseek/README.md
  59. 1 1
      packages/llm/llm-deepseek/README.zh.md
  60. 2 2
      packages/llm/llm-pi-ai/README.i18n.yaml
  61. 2 2
      packages/llm/llm-pi-ai/README.md
  62. 2 2
      packages/llm/llm-pi-ai/README.zh.md
  63. 2 2
      packages/llm/llm/README.i18n.yaml
  64. 0 0
      packages/llm/llm/README.md
  65. 2 2
      packages/llm/llm/README.zh.md
  66. 5 6
      packages/llm/llm/src/types.ts
  67. 2 2
      packages/llm/token-meter/README.i18n.yaml
  68. 4 2
      packages/llm/token-meter/README.md
  69. 4 2
      packages/llm/token-meter/README.zh.md
  70. 1 1
      packages/llm/token-meter/src/breakdown-projection.ts
  71. 6 1
      packages/llm/token-meter/src/estimate.ts
  72. 12 0
      packages/llm/token-meter/src/index.ts
  73. 1 1
      packages/llm/token-meter/src/usage-projection.ts
  74. 3 3
      packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
  75. 40 6
      packages/llm/token-meter/tests/route-pricing.spec.ts
  76. 1 1
      packages/llm/token-meter/tests/token-usage-projection.spec.ts
  77. 0 6
      pnpm-lock.yaml
  78. 43 0
      scripts/fixtures/python-snapshot-image-offload.mjs
  79. 9 0
      scripts/smoke-python-runtime.py
  80. 321 93
      scripts/snapshots/python-sdk-single-exe/advanced/result.json
  81. 5 1
      scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl
  82. 5 0
      scripts/type-equiv.manifest.json
  83. 3 4
      snapshots/sdk/inline-image-prompt/session.v3.jsonl

+ 6 - 0
.agents/notes/archived/architecture/2026-09-02-durable-image-offload.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/archived/architecture/2026-09-02-durable-image-offload.md
+2026-09-02-durable-image-offload.md: db03e9f52499c1790b3a0c03853f3aea23315bec
+2026-09-02-durable-image-offload.zh.md: 2b6d0a4aa65f9a39b88888506f7e85b4cfff48cf

+ 1 - 0
.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md → .agents/notes/archived/architecture/2026-09-02-durable-image-offload.md

@@ -1,6 +1,7 @@
 # Agent Note: Durable image offload by surface replacement
 
 Status: implemented
+Archived: 2026-09-10
 
 English | [中文](2026-09-02-durable-image-offload.zh.md)
 

+ 1 - 0
.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md → .agents/notes/archived/architecture/2026-09-02-durable-image-offload.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: 以表层替换实现持久的图片 offload
 
 Status: implemented
+Archived: 2026-09-10
 
 [English](2026-09-02-durable-image-offload.md) | 中文
 

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -358,6 +358,9 @@
     "architecture/2026-09-01-streamed-tool-call-identity.i18n.yaml": "sha256:5193bf7429a0300f743ea7e6087c3175a7d28c2c468ca1cfd1f77702b68b6399",
     "architecture/2026-09-01-streamed-tool-call-identity.md": "sha256:303433b95c22571bf6a801da604b42a4c7231f5f6d07daeb518354f0d7019141",
     "architecture/2026-09-01-streamed-tool-call-identity.zh.md": "sha256:53728d3ee06b29d6023e28024fb3ab6fa4a7473ae5e55368cd19690719d120f3",
+    "architecture/2026-09-02-durable-image-offload.i18n.yaml": "sha256:d06155597e7df4ca4c15a2902ab664d69cc2fde1e7963e61543d20a49930d2b9",
+    "architecture/2026-09-02-durable-image-offload.md": "sha256:02b7becba3dd73a4e627b85705e8bd5dd55978cb041f4d3a0fd1420a76e4de92",
+    "architecture/2026-09-02-durable-image-offload.zh.md": "sha256:a0464b8272086d663ee698efd0b2edd6224d40770d7a8fd88a6d8bfe030b2a1d",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.i18n.yaml → .agents/notes/implemented/architecture/2026-09-10-image-offload-events.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md
-2026-09-02-durable-image-offload.md: d4cc791deec64567b6920c878cb39439fb51c24d
-2026-09-02-durable-image-offload.zh.md: b31a4747db98328ea445d5891d1c813631aeaa94
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-image-offload-events.md
+2026-09-10-image-offload-events.md: d9ade125733422cbb597669e4f5dce0d042b1d7c
+2026-09-10-image-offload-events.zh.md: 7d439fc71caadf409dc73182162b2c5cf6e439e4

+ 47 - 0
.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md

@@ -0,0 +1,47 @@
+# Agent Note: Durable image offload events
+
+Status: implemented
+
+English | [中文](2026-09-10-image-offload-events.zh.md)
+
+## Problem
+
+A request can exceed an image-capable route's byte or image-count budget. Recomputing the omitted prefix for each request lets a route switch, inline fallback, or compaction restore previously omitted images. The model-visible image set then depends on unlogged request preparation. The [unified image pipeline](../feature/2026-08-20-unified-image-request-pipeline.md) owns attachment normalization and request versions; durable omission needs its own recorded decision.
+
+## Decision
+
+`dsh-compaction-image-offload` owns recovery on the `agent/request-error` waterfall. An `IMAGE_OFFLOAD_REQUIRED` failure supplies `offloadImages`; the plugin selects that many oldest retained input-image occurrences in current surface order, appends one `image/offload` event, and returns `retry`. Assistant output images are excluded. If no retained input image remains, the plugin delegates. Other failures do not enter this recovery. Offload does not consume the provider retry budget or emit `llm/retry`.
+
+The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including nested tool results and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set.
+
+The event has no `surfaceOp`. It neither creates a message nor replaces a message node. Session validates all targets before commit and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log.
+
+Selection, budgets, and retry policy stay in the compaction plugin. Session owns only durable validation and reconstruction. The instance `deriveEventMessage()` and `deriveMessages()` return projected messages; pure reconstructors pass `foldSurface(events).offloadedMessages` to the exported `deriveEventMessage()`. Rewriters preserving images must consume projected messages, including tool-result pruning and summarizer input.
+
+`contentGeneration` advances for message replacements and image-offload events so cached history and request series are refreshed. `replaceGeneration` advances only for replacements; image omission cannot masquerade as successful text compaction. An unchanged request envelope gets a `request/header` with reason `series`; a simultaneous envelope change carries `startsSeries: true`.
+
+Adapters prepare only retained images and render marked occurrences as `offloadedImageText` with the current execution-world access path. Route-local budget checks report required counts, including the tighter inline fallback budget, but do not edit history. These checks are provisional: a provider failure that identifies uncacheable images can replace local counting without moving the durable decision into the adapter.
+
+Token accounting applies selections to the affected nodes' image occurrences without replacing their identities or changing prior usage-anchor snapshots. Route pricing substitutes placeholder text for image cost. The fixed structural heuristic excludes the `offloaded` marker, so image omission does not change that heuristic or require a `compaction/prune` shadow price. The context-pressure and context-breakdown checkpoint versions advance to reject cached estimates that counted the marker.
+
+`image/offload` is required on read. Its addition changes the recognized event vocabulary, not the Session envelope or operation grammar, so it does not advance `SESSION_FORMAT_VERSION`. A reader without this event type refuses the log instead of silently restoring omitted images.
+
+## Alternatives considered
+
+**Reuse `compaction/prune` and `surfaceOp: replace`.** The [archived replacement design](../../archived/architecture/2026-09-02-durable-image-offload.md) records full carrying-message copies and couples omission to node replacement and shadow-price accounting. A dedicated event expresses which images are omitted while retaining message identity and keeping retry policy in the same plugin.
+
+**Record a request-local projection outcome.** A record that does not affect subsequent derivation cannot prevent omitted images from returning. Logging whole request bodies repeats message history for a decision that needs only occurrence indexes.
+
+**Use an attachment id or a single watermark.** Attachments can occur repeatedly, and surface order can differ from event-sequence order. Explicit per-node indexes distinguish these cases without redefining a watermark after every replacement.
+
+**Move selection into the loop or adapters.** The request-error extension already owns repair and retry. The adapter knows the route budget; the plugin knows the session input order. Neither requires route-specific policy in the shared loop.
+
+## Consequences
+
+Omission remains effective when a route budget grows, an inline fallback ends, or compaction reduces context. It does not delete attachment bytes. Reading the placeholder's access path creates a new image occurrence that can be retained independently. The execution-world path is still resolved during serialization, as it is for retained image handles; this event records the selected image set, not filesystem mappings.
+
+Session adds a required event fold and one content-generation signal. Consumers reconstructing model input must use projected messages. Human views can still show original images from immutable events. The plugin no longer requires token-meter or compaction services merely to record omission.
+
+## Testing
+
+Session tests cover nested and repeated occurrences, atomic rejection, cache invalidation, pure folding, restore, fork, and untouched source events. Plugin tests cover consecutive failures, current surface order, exhausted recovery, fresh request headers, and disposal. Token-meter tests preserve node identity and prior usage anchors while repricing only selected occurrences. Tool-result pruning and compaction tests ensure later rewrites consume the projected history. TypeScript and Python SDK expected outputs exercise the required event through shipped profiles.

+ 47 - 0
.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md

@@ -0,0 +1,47 @@
+# Agent Note:持久图片省略事件
+
+Status: implemented
+
+[English](2026-09-10-image-offload-events.md) | 中文
+
+## 问题
+
+请求可能超过图片路由的字节或图片数量预算。每次请求重新计算省略前缀,会让路由切换、内联回退或压缩恢复先前省略的图片。模型可见的图片集合因此取决于未记录的请求准备过程。[统一图片流水线](../feature/2026-08-20-unified-image-request-pipeline.zh.md)负责附件归一化与请求版本,持久省略需要单独记录决策。
+
+## 决策
+
+`dsh-compaction-image-offload` 在 `agent/request-error` waterfall 上负责恢复。`IMAGE_OFFLOAD_REQUIRED` 失败提供 `offloadImages`,插件按当前 surface 顺序选择该数量最旧且仍保留的输入图片出现位置,追加一个 `image/offload` 事件,再返回 `retry`。Assistant 输出图片不参与选择。没有保留的输入图片时,插件委托后续处理。其他失败不进入这项恢复。图片省略不消耗提供方重试预算,也不产生 `llm/retry`。
+
+事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括嵌套工具结果和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。
+
+事件不带 `surfaceOp`,不创建消息,也不替换消息节点。Session 在提交前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。
+
+选择策略、预算和重试策略保留在 compaction 插件中。Session 只负责持久数据校验和重建。实例方法 `deriveEventMessage()` 与 `deriveMessages()` 返回派生消息,纯重建函数将 `foldSurface(events).offloadedMessages` 传给导出的 `deriveEventMessage()`。需要保留图片的改写必须读取派生消息,包括工具结果裁剪和摘要输入。
+
+消息替换和图片省略事件都会推进 `contentGeneration`,使历史缓存和请求系列刷新。`replaceGeneration` 只随替换推进,图片省略不能被判断为文本压缩成功。请求配置未变时写入原因是 `series` 的 `request/header`,配置同时变化时携带 `startsSeries: true`。
+
+适配器只准备保留的图片,将带标记的位置渲染为包含当前执行环境访问路径的 `offloadedImageText`。路由本地预算检查报告所需数量,包括内联回退的更低预算,但不编辑历史。这些检查是暂时方案,未来提供方报告无法缓存的图片时,可以替代本地计数,无需把持久决策移入适配器。
+
+Token 计量将选择应用到受影响节点的图片出现位置,不替换节点身份,也不修改已有用量锚点快照。路由计价以占位文本替代图片费用。固定结构启发式不计 `offloaded` 标记,因此图片省略不改变该启发式,也不需要 `compaction/prune` 的遮蔽价格。上下文压力和分类明细检查点版本递增,拒绝曾将该标记计入的缓存估算。
+
+`image/offload` 是读取时必须识别的事件。它增加已知事件类型,不改变 Session 信封或操作语法,因此不推进 `SESSION_FORMAT_VERSION`。不认识该类型的读取器会拒绝日志,避免静默恢复已省略的图片。
+
+## 考虑过的替代方案
+
+**复用 `compaction/prune` 和 `surfaceOp: replace`。** [已归档的替换设计](../../archived/architecture/2026-09-02-durable-image-offload.md)记录携带图片的完整消息副本,把图片省略与节点替换和遮蔽价格计量绑定。专用事件表达被省略的图片,保留消息身份,重试策略仍留在同一个插件中。
+
+**记录单次请求的投影结果。** 不影响后续派生的记录无法阻止省略的图片返回。记录完整请求体会重复消息历史,而决策只需要图片出现位置索引。
+
+**使用附件 ID 或单个分界点。** 附件可能重复出现,surface 顺序也可能与事件序号顺序不同。逐节点记录明确索引能够区分这些情况,无需在每次替换后重新定义分界点。
+
+**把选择移入循环或适配器。** 请求错误扩展点已经负责修复与重试。适配器知道路由预算,插件知道会话输入顺序,两者都不需要在共享循环中加入路由策略。
+
+## 影响
+
+路由预算增加、内联回退结束或压缩减少上下文时,省略仍然有效。省略不删除附件字节。读取占位文本中的访问路径会创建新的图片出现位置,可以独立保留。执行环境路径仍在序列化时解析,与保留图片的标识文本一致。这个事件记录选择的图片集合,不记录文件系统映射。
+
+Session 增加必需事件的折叠和一个内容版本信号。重建模型输入的消费者必须使用派生消息。面向人的视图仍可从不可变事件显示原始图片。插件记录省略时不再需要 token-meter 或 compaction 服务。
+
+## 测试
+
+Session 测试覆盖嵌套和重复图片、原子拒绝、缓存失效、纯折叠、恢复、分叉及原始事件不变。插件测试覆盖连续失败、当前 surface 顺序、无图可省略时的恢复、新请求头和释放。Token-meter 测试在仅重计所选位置价格时保留节点身份和已有用量锚点。工具结果裁剪和压缩测试保证后续改写读取派生历史。TypeScript 和 Python SDK 预期输出通过发布的 profile 覆盖必需事件。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
-2026-08-20-unified-image-request-pipeline.md: 736c8fb7f6f58db137d2b3cb9f5a007db16dd67b
-2026-08-20-unified-image-request-pipeline.zh.md: 532662291ba2c7257906a2bf4f52792d5285ec1c
+2026-08-20-unified-image-request-pipeline.md: 285f380909ef744078daa429e9fc06ca609cb530
+2026-08-20-unified-image-request-pipeline.zh.md: 4d7db2871b88d6656b58e6d47dc77ab7b91cf4c4

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md

@@ -26,7 +26,7 @@ Batch admission prepares and verifies every normalized attachment once before pu
 
 The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, and alpha without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared.
 
-Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a per-image placeholder that retains its identity and access resolved for the current tool execution world, including nested tool-result images, while append-only session history keeps the original references.
+Request-size offload follows the [durable image-offload decision](../architecture/2026-09-10-image-offload-events.md). Routes check retained occurrences against their request-version byte and image-count budgets and report `IMAGE_OFFLOAD_REQUIRED` with a removal count. The compaction plugin records exact oldest-first occurrences in `image/offload`; Session derivation applies the marks. Adapters prepare only retained attachments and render each marked occurrence as placeholder text with its identity and current execution-world access path, including nested tool-result images. Original message events retain the attachment references.
 
 ### Stable handles
 
@@ -62,7 +62,7 @@ Historical attachment objects that later disappear or fail integrity verificatio
 
 ## Verification
 
-Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, stop lazy encoding after the first fitting candidate, keep the smallest ladder output above an unreachable byte target, cover square and wide 640,000-pixel projections, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry.
+Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, stop lazy encoding after the first fitting candidate, keep the smallest ladder output above an unreachable byte target, cover square and wide 640,000-pixel projections, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for durably offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry.
 
 ## Consequences
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md

@@ -26,7 +26,7 @@ Status: implemented
 
 `variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸和透明通道,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。
 
-请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成逐图占位文本,保留自己的身份和本次工具执行环境解析出的访问方式,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。
+请求大小 offload 遵循[持久图片省略决策](../architecture/2026-09-10-image-offload-events.zh.md)。路由根据请求版本字节和图片数量预算检查保留的位置,通过 `IMAGE_OFFLOAD_REQUIRED` 报告所需移除数量。Compaction 插件在 `image/offload` 中记录明确的最旧图片出现位置,Session 派生时应用标记。适配器只准备保留的附件,将每个带标记的位置渲染为包含其身份和当前执行环境访问路径的占位文本,嵌套工具结果图片也适用。原始消息事件保留附件引用。
 
 ### 稳定句柄
 
@@ -62,7 +62,7 @@ Status: implemented
 
 ## Verification
 
-包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、首个候选合规后停止编码、字节目标不可达时保留最小阶梯产物、正方形和宽屏 640,000 像素投影、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。
+包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、首个候选合规后停止编码、字节目标不可达时保留最小阶梯产物、正方形和宽屏 640,000 像素投影、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已持久省略的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。
 
 ## Consequences
 

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: 688341582044e72e8548c8e6b1535450793ddce4
-architecture.zh.md: 6817986015a2aa90d6fcb094da3e972ccfe08483
+architecture.md: 83394c1f3055a200d56c129154ad06c1d0fb741f
+architecture.zh.md: 0f60930304677ea3d2b8dbd571a99533d56bff09

+ 1 - 1
docs/architecture.md

@@ -106,7 +106,7 @@ turn/end
 
 Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
 
-`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
+`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
 
 The loop sends immutable requests while keeping cancellation live. It reuses message-freeze provenance only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules.
 

+ 1 - 1
docs/architecture.zh.md

@@ -110,7 +110,7 @@ turn/end
 
 输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
 
-`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
+`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
 
 循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。
 

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 80cbbe1e52b67fb26115af236220505190e5e4b3
-config-catalog.zh.md: fbdfbb6393b39b468ee29c1ce121def4f05224a7
+config-catalog.md: 07788bc02bc0abc049a72cab978e1565a5be0453
+config-catalog.zh.md: 58229a8415a6a76d3fad160bf9c06a47d8f84a33

+ 2 - 2
docs/config-catalog.md

@@ -488,14 +488,14 @@ Source: [`packages/compaction/compaction-basic/src/types.ts:38`](../packages/com
 
 ## `@deepseek-ai/dsh-compaction-image-offload`
 
-Requires: `agents` · `tokenMeter`
+Requires: `agents`
 
 ```ts config-catalog
 /** The executor has no configuration; image-capable routes own their budgets. */
 export type Config = Readonly<Record<string, never>>
 ```
 
-Source: [`packages/compaction/compaction-image-offload/src/index.ts:23`](../packages/compaction/compaction-image-offload/src/index.ts)
+Source: [`packages/compaction/compaction-image-offload/src/index.ts:21`](../packages/compaction/compaction-image-offload/src/index.ts)
 
 <a id="deepseek-aidsh-compaction-tool-result-pruner"></a>
 

+ 2 - 2
docs/config-catalog.zh.md

@@ -490,14 +490,14 @@ export interface ModelCompactPolicyConfig extends CompactionPolicyConfig {
 
 ## `@deepseek-ai/dsh-compaction-image-offload`
 
-需要:`agents` · `tokenMeter`
+需要:`agents`
 
 ```ts config-catalog
 /** The executor has no configuration; image-capable routes own their budgets. */
 export type Config = Readonly<Record<string, never>>
 ```
 
-来源:[`packages/compaction/compaction-image-offload/src/index.ts:23`](../packages/compaction/compaction-image-offload/src/index.ts)
+来源:[`packages/compaction/compaction-image-offload/src/index.ts:21`](../packages/compaction/compaction-image-offload/src/index.ts)
 
 <a id="deepseek-aidsh-compaction-tool-result-pruner"></a>
 

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: ee27139709884395c0ca27c92f1b6bf1c5238412
-module-graph.zh.md: 36ac7a369faebbf318f4da2210fbd8692e948f29
+module-graph.md: 8d12245db52ff973c10d5157c6cc67b6dadb71ee
+module-graph.zh.md: 5ac44d3ea182b5496b5a07dc82a31cfd0a4bf1fe

+ 4 - 6
docs/module-graph.md

@@ -559,6 +559,9 @@ flowchart TD
   pkg_api_workspace_controller --> pkg_storage_domain
   pkg_api_workspace_controller --> pkg_typert_protocol
   pkg_api_workspace_controller --> pkg_workspace
+  pkg_compaction_image_offload --> pkg_agent
+  pkg_compaction_image_offload --> pkg_llm
+  pkg_compaction_image_offload --> pkg_session
   pkg_file_reference --> pkg_agent
   pkg_time_context --> pkg_agent
   pkg_time_context --> pkg_invariants
@@ -920,11 +923,6 @@ flowchart TD
   pkg_api_settings_controller --> pkg_typert_protocol
   pkg_web_app --> pkg_shell_env
   pkg_web_app --> pkg_system_prompt
-  pkg_compaction_image_offload --> pkg_agent
-  pkg_compaction_image_offload --> pkg_compaction
-  pkg_compaction_image_offload --> pkg_llm
-  pkg_compaction_image_offload --> pkg_session
-  pkg_compaction_image_offload --> pkg_token_meter
   pkg_compaction_tool_result_pruner --> pkg_compaction
   pkg_compaction_tool_result_pruner --> pkg_llm
   pkg_compaction_tool_result_pruner --> pkg_session
@@ -1348,6 +1346,7 @@ flowchart TD
 | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) |
 | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
 | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
+| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
 | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
@@ -1419,7 +1418,6 @@ flowchart TD
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
 | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
-| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |

+ 4 - 6
docs/module-graph.zh.md

@@ -561,6 +561,9 @@ flowchart TD
   pkg_api_workspace_controller --> pkg_storage_domain
   pkg_api_workspace_controller --> pkg_typert_protocol
   pkg_api_workspace_controller --> pkg_workspace
+  pkg_compaction_image_offload --> pkg_agent
+  pkg_compaction_image_offload --> pkg_llm
+  pkg_compaction_image_offload --> pkg_session
   pkg_file_reference --> pkg_agent
   pkg_time_context --> pkg_agent
   pkg_time_context --> pkg_invariants
@@ -922,11 +925,6 @@ flowchart TD
   pkg_api_settings_controller --> pkg_typert_protocol
   pkg_web_app --> pkg_shell_env
   pkg_web_app --> pkg_system_prompt
-  pkg_compaction_image_offload --> pkg_agent
-  pkg_compaction_image_offload --> pkg_compaction
-  pkg_compaction_image_offload --> pkg_llm
-  pkg_compaction_image_offload --> pkg_session
-  pkg_compaction_image_offload --> pkg_token_meter
   pkg_compaction_tool_result_pruner --> pkg_compaction
   pkg_compaction_tool_result_pruner --> pkg_llm
   pkg_compaction_tool_result_pruner --> pkg_session
@@ -1350,6 +1348,7 @@ flowchart TD
 | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) |
 | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) |
 | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
+| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
 | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
@@ -1421,7 +1420,6 @@ flowchart TD
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
 | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
-| [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | `compaction` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 46e91f23a7aad053791df190f769ad911f334b68
-persistence-catalog.zh.md: 087915b83d4dfd5c45a1583658d6ebe091904a3f
+persistence-catalog.md: 8621cd16ad625e0c73f56faf197aa095f1fa8410
+persistence-catalog.zh.md: 99589be298ea3e42285fa85476c28effcd476a6c

+ 34 - 14
docs/persistence-catalog.md

@@ -83,7 +83,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 ```
 
-Sources: [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:421`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:451`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:482`](../packages/core/session/src/types.ts)
 
 ## Events
 
@@ -210,7 +210,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter
 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
 ```
 
-Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts)
 
 <a id="assistantmessage--surface"></a>
 
@@ -240,7 +240,7 @@ Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/
 
 Types: [TokenUsage](subsystems/llm-streaming.md)
 
-Source: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts)
 
 ### `command/*`
 
@@ -514,6 +514,26 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook-
 
 Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts)
 
+### `image/*`
+
+<a id="imageoffload--log-only"></a>
+
+#### `image/offload` — log-only
+
+```ts persistence-catalog
+/**
+ * Permanently omit the selected input-image occurrences from subsequent
+ * model requests. Each target names a current user/message or tool/result
+ * node; image indexes are zero-based depth-first positions within that
+ * message, including nested tool results and already offloaded images.
+ * Targets are unique and each index list is nonempty and strictly increasing.
+ * This event changes derived content without replacing message nodes.
+ */
+'image/offload': { targets: ImageOffloadTarget[] }
+```
+
+Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
+
 ### `llm/*`
 
 <a id="llmretry--log-only"></a>
@@ -605,7 +625,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/s
 'request/context': RequestContext
 ```
 
-Source: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts)
 
 <a id="requestheader--log-only"></a>
 
@@ -624,7 +644,7 @@ Source: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/
 }
 ```
 
-Source: [`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts)
 
 ### `sandbox/*`
 
@@ -699,7 +719,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch
 'session/end-seed': { inherited?: true }
 ```
 
-Source: [`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:409`](../packages/core/session/src/types.ts)
 
 <a id="sessiontitle--log-only"></a>
 
@@ -761,7 +781,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/se
 'step/end': { turn: number; step: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts)
 
 <a id="stepstart--log-only"></a>
 
@@ -772,7 +792,7 @@ Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/
 'step/start': { turn: number; step: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts)
 
 ### `subagent/*`
 
@@ -848,7 +868,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p
 'system/message': { turn: number; step: number; message: SystemMessage }
 ```
 
-Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts)
 
 ### `team/*`
 
@@ -941,7 +961,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s
 
 Types: [ToolCallId](subsystems/core.md)
 
-Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts)
 
 <a id="toolptc-dispatch--log-only"></a>
 
@@ -1017,7 +1037,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
 }
 ```
 
-Source: [`packages/core/session/src/types.ts:353`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts)
 
 ### `tool-workflow/*`
 
@@ -1097,7 +1117,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow
 
 Types: [TurnEndReason](subsystems/session.md)
 
-Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
 
 <a id="turnstart--log-only"></a>
 
@@ -1113,7 +1133,7 @@ Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/
 'turn/start': { turn: number }
 ```
 
-Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
 
 ### `user/*`
 
@@ -1132,7 +1152,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/
 'user/message': UserMessage
 ```
 
-Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts)
 
 ### `web/*`
 

+ 34 - 14
docs/persistence-catalog.zh.md

@@ -85,7 +85,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }[T]
 ```
 
-来源:[`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:434`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:421`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:429`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:451`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:482`](../packages/core/session/src/types.ts)
 
 ## 事件
 
@@ -212,7 +212,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
 ```
 
-来源:[`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts)
 
 <a id="assistantmessage--surface"></a>
 
@@ -242,7 +242,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[TokenUsage](subsystems/llm-streaming.zh.md)
 
-来源:[`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts)
 
 ### `command/*`
 
@@ -516,6 +516,26 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 来源:[`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts)
 
+### `image/*`
+
+<a id="imageoffload--log-only"></a>
+
+#### `image/offload` — log-only
+
+```ts persistence-catalog
+/**
+ * Permanently omit the selected input-image occurrences from subsequent
+ * model requests. Each target names a current user/message or tool/result
+ * node; image indexes are zero-based depth-first positions within that
+ * message, including nested tool results and already offloaded images.
+ * Targets are unique and each index list is nonempty and strictly increasing.
+ * This event changes derived content without replacing message nodes.
+ */
+'image/offload': { targets: ImageOffloadTarget[] }
+```
+
+来源:[`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
+
 ### `llm/*`
 
 <a id="llmretry--log-only"></a>
@@ -607,7 +627,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'request/context': RequestContext
 ```
 
-来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:386`](../packages/core/session/src/types.ts)
 
 <a id="requestheader--log-only"></a>
 
@@ -626,7 +646,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }
 ```
 
-来源:[`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts)
 
 ### `sandbox/*`
 
@@ -701,7 +721,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'session/end-seed': { inherited?: true }
 ```
 
-来源:[`packages/core/session/src/types.ts:400`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:409`](../packages/core/session/src/types.ts)
 
 <a id="sessiontitle--log-only"></a>
 
@@ -763,7 +783,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'step/end': { turn: number; step: number }
 ```
 
-来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts)
 
 <a id="stepstart--log-only"></a>
 
@@ -774,7 +794,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'step/start': { turn: number; step: number }
 ```
 
-来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts)
 
 ### `subagent/*`
 
@@ -850,7 +870,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'system/message': { turn: number; step: number; message: SystemMessage }
 ```
 
-来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts)
 
 ### `team/*`
 
@@ -943,7 +963,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[ToolCallId](subsystems/core.zh.md)
 
-来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts)
 
 <a id="toolptc-dispatch--log-only"></a>
 
@@ -1019,7 +1039,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 }
 ```
 
-来源:[`packages/core/session/src/types.ts:353`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts)
 
 ### `tool-workflow/*`
 
@@ -1099,7 +1119,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[TurnEndReason](subsystems/session.zh.md)
 
-来源:[`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
 
 <a id="turnstart--log-only"></a>
 
@@ -1115,7 +1135,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'turn/start': { turn: number }
 ```
 
-来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
 
 ### `user/*`
 
@@ -1134,7 +1154,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'user/message': UserMessage
 ```
 
-来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts)
+来源:[`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts)
 
 ### `web/*`
 

+ 2 - 2
docs/subsystems/llm-streaming.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
-llm-streaming.md: 087e7efc5dbe150706a0f64cc41719e2f13b297b
-llm-streaming.zh.md: 04c8ed89bc21f0ffdb747ee4553e70a8fcfc7f4b
+llm-streaming.md: 0971ebab7179403f7b12c08ba5ccab43ef9bd959
+llm-streaming.zh.md: 18bc1df59d51c4929a8b347bb193323dd8b50975

+ 3 - 4
docs/subsystems/llm-streaming.md

@@ -247,9 +247,8 @@ interface LlmFailure {
   /**
    * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained
    * image occurrences the route needs offloaded before the same request fits
-   * its exact byte accounting. `dsh-compaction-image-offload` replaces the
-   * surface nodes carrying that many oldest retained occurrences with copies
-   * marked `offloaded` and retries the step.
+   * its exact byte accounting. `dsh-compaction-image-offload` records the
+   * selected occurrences in an `image/offload` event and retries the step.
    */
   readonly offloadImages?: number
 }
@@ -257,7 +256,7 @@ interface LlmFailure {
 
 ## Request-image pricing
 
-An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter prices each retained occurrence at its per-model pixel-budget projection with the published v4 vision accounting and each occurrence a surface replacement marks offloaded as its placeholder text, while provider usage remains the authoritative anchor for completed requests.
+An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter prices each retained occurrence at its per-model pixel-budget projection with the published v4 vision accounting and each occurrence selected by a logged image-offload decision as its placeholder text, while provider usage remains the authoritative anchor for completed requests.
 
 ```ts type-equiv
 /**

+ 3 - 4
docs/subsystems/llm-streaming.zh.md

@@ -249,9 +249,8 @@ interface LlmFailure {
   /**
    * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained
    * image occurrences the route needs offloaded before the same request fits
-   * its exact byte accounting. `dsh-compaction-image-offload` replaces the
-   * surface nodes carrying that many oldest retained occurrences with copies
-   * marked `offloaded` and retries the step.
+   * its exact byte accounting. `dsh-compaction-image-offload` records the
+   * selected occurrences in an `image/offload` event and retries the step.
    */
   readonly offloadImages?: number
 }
@@ -259,7 +258,7 @@ interface LlmFailure {
 
 ## 请求图片定价
 
-提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器按模型像素预算的投影用官方公布的 v4 视觉计量为每个保留的出现位置定价,并把表层替换标记为已省略的出现位置按其占位文本定价,已完成请求仍以 provider usage 为权威锚点。
+提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器按模型像素预算的投影用官方公布的 v4 视觉计量为每个保留的出现位置定价,并把日志中的图片省略决策选中的出现位置按其占位文本定价,已完成请求仍以 provider usage 为权威锚点。
 
 ```ts type-equiv
 /**

+ 2 - 2
docs/subsystems/session.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: 2b99ca8267d35978f245e6bdbf020226c976e363
-session.zh.md: e6cff2d239caf7cd99be13463b412479a6f6e79b
+session.md: f29790c1667e40f5d0307529941b75759743b394
+session.zh.md: 696970d2305544cafcf3c01bdb8d1503e9cee8f9

+ 33 - 8
docs/subsystems/session.md

@@ -25,6 +25,15 @@ interface UserMessage extends Message {
  * compact raw streams so persistence stores one durable settlement per attempt.
  */
 interface SessionEventMap {
+  /**
+   * Permanently omit the selected input-image occurrences from subsequent
+   * model requests. Each target names a current user/message or tool/result
+   * node; image indexes are zero-based depth-first positions within that
+   * message, including nested tool results and already offloaded images.
+   * Targets are unique and each index list is nonempty and strictly increasing.
+   * This event changes derived content without replacing message nodes.
+   */
+  'image/offload': { targets: ImageOffloadTarget[] }
   /**
    * Opens turn `turn` before the loop claims queued input or runs pre-step.
    * Rejection, empty input, cancellation, or failure may close it with no
@@ -339,6 +348,18 @@ Required for `SurfaceEventType` events — every message-producing event must de
 
 `assistant/message` cannot carry `sourceEventSeqs`; its `stream` owns exact provider evidence. Other surface events omit the field when they cite no earlier event and use a complete non-empty list when they do.
 
+`image/offload` uses exact per-node indexes. It changes derived input content without adding a surface node; `contentGeneration` advances so request series and cached derivation are refreshed. Reconstructors apply `foldSurface(events).offloadedMessages` through `deriveEventMessage(event, offloadedMessages)`.
+
+```ts type-equiv
+/** Exact input-image occurrences selected by one durable offload decision. */
+interface ImageOffloadTarget {
+  /** Current message-producing event containing these occurrences. */
+  seq: SessionSeq
+  /** Zero-based depth-first image indexes within the immutable message. */
+  imageIndexes: number[]
+}
+```
+
 ### `SessionSurface` — the live readonly surface projection
 
 `Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation.
@@ -352,6 +373,8 @@ interface SessionSurface {
   readonly nodes: readonly SessionSeq[]
   /** Monotonic count of committed positional replacements. */
   readonly replaceGeneration: number
+  /** Monotonic count of committed replacements and image-offload decisions. */
+  readonly contentGeneration: number
 }
 ```
 
@@ -380,6 +403,8 @@ interface SurfaceFoldResult {
   nodes: SessionSeq[]
   /** Replacement operations in event order. */
   replacements: SurfaceFoldReplacement[]
+  /** Immutable image-offloaded messages, keyed by their original event sequences. */
+  offloadedMessages: ReadonlyMap<SessionSeq, Message>
 }
 ```
 
@@ -566,21 +591,21 @@ declare class Session {
    * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
    * turn boundary) is correctly absent, and a compaction `replace` deletes the
    * shadowed nodes from the derivation. The projection rules are
-   * {@link deriveEventMessage}, folded per node.
+   * {@link deriveEventMessage}, with logged image-offload selections applied
+   * without changing node membership or message identity.
    *
-   * CACHED: each surface node is projected exactly once, when first seen — a
-   * call costs O(new nodes), and a surface rewrite (a `replace`;
-   * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
+   * CACHED: pure tail growth costs O(new nodes); a replacement or image offload
+   * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is
    * a fresh snapshot per call (later appends never grow an array a caller
    * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
-   * Their content reuses the already frozen durable event data, so the cache
-   * needs no second deep clone and consumers still cannot mutate the log.
+   * Unchanged content reuses frozen event data; offloaded blocks are frozen
+   * derived copies. Consumers cannot mutate the log through either form.
    * @returns a fresh array of the shared, frozen derived history.
    */
   deriveMessages(): Message[];
   /**
-   * Instance face of the pure per-node `deriveEventMessage` export from
-   * `surface.ts`.
+   * Project one event with all committed image-offload selections applied.
+   * The original durable event remains unchanged.
    * @param event - the event to project.
    * @returns the derived message, or null when the event produces none.
    */

+ 33 - 8
docs/subsystems/session.zh.md

@@ -25,6 +25,15 @@ interface UserMessage extends Message {
  * compact raw streams so persistence stores one durable settlement per attempt.
  */
 interface SessionEventMap {
+  /**
+   * Permanently omit the selected input-image occurrences from subsequent
+   * model requests. Each target names a current user/message or tool/result
+   * node; image indexes are zero-based depth-first positions within that
+   * message, including nested tool results and already offloaded images.
+   * Targets are unique and each index list is nonempty and strictly increasing.
+   * This event changes derived content without replacing message nodes.
+   */
+  'image/offload': { targets: ImageOffloadTarget[] }
   /**
    * Opens turn `turn` before the loop claims queued input or runs pre-step.
    * Rejection, empty input, cancellation, or failure may close it with no
@@ -341,6 +350,18 @@ type SurfaceIntent<T extends SurfaceEventType = SurfaceEventType> = {
 
 `assistant/message` 不能携带 `sourceEventSeqs`;它的 `stream` 拥有精确 provider 证据。其他 surface event 不引用较早 event 时省略该字段,需要引用时使用完整非空 list。
 
+`image/offload` 使用逐节点的明确索引,不增加 surface 节点,但改变派生输入内容。`contentGeneration` 推进,使请求系列和派生缓存刷新。重建函数通过 `deriveEventMessage(event, offloadedMessages)` 应用 `foldSurface(events).offloadedMessages`。
+
+```ts type-equiv
+/** Exact input-image occurrences selected by one durable offload decision. */
+interface ImageOffloadTarget {
+  /** Current message-producing event containing these occurrences. */
+  seq: SessionSeq
+  /** Zero-based depth-first image indexes within the immutable message. */
+  imageIndexes: number[]
+}
+```
+
 ### `SessionSurface`:实时只读 surface 投影
 
 `Session.surface` 返回会话稳定的 `SessionSurface` 视图。同一个增量管理器在提交前校验追加候选事件,并根据已提交事件推进该投影;调用方可以观察成员关系和替换代次,但不能调用校验。
@@ -354,6 +375,8 @@ interface SessionSurface {
   readonly nodes: readonly SessionSeq[]
   /** Monotonic count of committed positional replacements. */
   readonly replaceGeneration: number
+  /** Monotonic count of committed replacements and image-offload decisions. */
+  readonly contentGeneration: number
 }
 ```
 
@@ -382,6 +405,8 @@ interface SurfaceFoldResult {
   nodes: SessionSeq[]
   /** Replacement operations in event order. */
   replacements: SurfaceFoldReplacement[]
+  /** Immutable image-offloaded messages, keyed by their original event sequences. */
+  offloadedMessages: ReadonlyMap<SessionSeq, Message>
 }
 ```
 
@@ -568,21 +593,21 @@ declare class Session {
    * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
    * turn boundary) is correctly absent, and a compaction `replace` deletes the
    * shadowed nodes from the derivation. The projection rules are
-   * {@link deriveEventMessage}, folded per node.
+   * {@link deriveEventMessage}, with logged image-offload selections applied
+   * without changing node membership or message identity.
    *
-   * CACHED: each surface node is projected exactly once, when first seen — a
-   * call costs O(new nodes), and a surface rewrite (a `replace`;
-   * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
+   * CACHED: pure tail growth costs O(new nodes); a replacement or image offload
+   * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is
    * a fresh snapshot per call (later appends never grow an array a caller
    * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
-   * Their content reuses the already frozen durable event data, so the cache
-   * needs no second deep clone and consumers still cannot mutate the log.
+   * Unchanged content reuses frozen event data; offloaded blocks are frozen
+   * derived copies. Consumers cannot mutate the log through either form.
    * @returns a fresh array of the shared, frozen derived history.
    */
   deriveMessages(): Message[];
   /**
-   * Instance face of the pure per-node `deriveEventMessage` export from
-   * `surface.ts`.
+   * Project one event with all committed image-offload selections applied.
+   * The original durable event remains unchanged.
    * @param event - the event to project.
    * @returns the derived message, or null when the event produces none.
    */

+ 22 - 0
packages/compaction/compaction-basic/tests/manual-compaction.spec.ts

@@ -237,6 +237,28 @@ function compactEvents(session: Session): SessionEvent[] {
 }
 
 describe('compactNow through the real loop', () => {
+  it('passes logged image omissions to the summarizer without changing the original message', async () => {
+    const { ctx, agent, compact } = await loopHarness()
+    try {
+      agent.followup(createUserMessage({
+        content: [{ type: 'text', text: PROMPT }, {
+          type: 'image',
+          attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
+        }],
+        source: { kind: 'user' },
+      }))
+      await agent.whenIdle()
+      const source = agent.session.snapshotEvents().find(event => event.type === 'user/message')!
+      agent.session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })
+      expect(await compact.compactNow(agent, SIGNAL)).not.toBeNull()
+      const image = compact.calls[0]?.messages.flatMap(message => message.content).find(block => block.type === 'image')
+      expect(image).toMatchObject({ offloaded: true })
+      expect(JSON.stringify(source)).not.toContain('offloaded')
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('holds a prompt accepted during summarization until the standalone bracket is flushed', async () => {
     const harness = await loopHarness()
     const { agent, compact, adapter, log } = harness

+ 2 - 2
packages/compaction/compaction-image-offload/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/compaction/compaction-image-offload/README.md
-README.md: 7c349e7f7d205108d9348485eb0bc988aa5af091
-README.zh.md: ba51fc001fbf57efdf1bd62f01cfd4403a85668a
+README.md: a34b8c43d0c1de2afa9d4bd4c9638c75bed98f51
+README.zh.md: 9d6c309eed697fcccde043fd87447bf1499410ec

+ 11 - 11
packages/compaction/compaction-image-offload/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Image-heavy conversations continue when older images exceed a model route's budget. The plugin permanently replaces those images with text naming each attachment and its read-only path, then retries the request without spending the provider retry budget. Later requests retain that choice across route changes, resume, and replay. Token accounting follows the replacements, and provider cache reuse ends at the first changed message.
+Image-heavy conversations continue when older images exceed a model route's budget. The plugin permanently replaces those images with text naming each attachment and its available read-only path, then retries without spending the provider retry budget. Later requests retain that choice across route changes, resume, and replay. Token accounting follows the logged selections, and provider cache reuse ends at the first changed message.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ Image-heavy conversations continue when older images exceed a model route's budg
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount this plugin in every composition that runs the agent loop with an image-capable route and the token meter. The shipped `dsh` base does. Without it, an `IMAGE_OFFLOAD_REQUIRED` failure reaches ordinary recovery and ends the turn as an error. The plugin has no configuration: the DeepSeek adapter enforces its file-mode and inline-fallback budgets, the pi-ai adapter its base64 bound, and each reports the count it needs offloaded.
+Mount this plugin in every composition that runs the agent loop with an image-capable route. The shipped `dsh` base does. Without it, an `IMAGE_OFFLOAD_REQUIRED` failure reaches ordinary recovery and ends the turn as an error. The plugin has no configuration: the DeepSeek adapter enforces its file-mode and inline-fallback budgets, the pi-ai adapter its base64 bound, and each reports the count it needs offloaded.
 
 ### Minimal configuration
 
@@ -35,7 +35,7 @@ Mount this plugin in every composition that runs the agent loop with an image-ca
 
 ### What you can observe
 
-Each offload appends, per replaced node, one `compaction/prune` event carrying the node's heuristic token price and then the replacement node itself: a `user/message` or `tool/result` copy of the original with the affected image blocks marked `offloaded: true`, a `surfaceOp` of `replace`, and `sourceEventSeqs` naming the original. The original event stays in the log untouched. The retried request follows; the loop logs a fresh `request/header` after the surface change, as it does after any compaction.
+Each decision appends one `image/offload` event identifying the selected occurrences by current message-event sequence and depth-first image index. The message events and surface node identities remain unchanged. The retried request follows a fresh `request/header` identifying a new message series.
 
 ### Failures and recovery
 
@@ -49,9 +49,9 @@ The plugin acts only on `IMAGE_OFFLOAD_REQUIRED` failures that carry `offloadIma
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The plugin is one function plugin with one `agent/request-error` listener. `offloadOldestImages()` walks `session.surface.nodes`, marks the first `offloadImages` retained occurrences across `user/message` and `tool/result` nodes, nested tool results included, and for each changed node appends the `compaction/prune` shadow price followed by the marked copy under `surfaceOp: { op: 'replace' }`. It reuses the compaction seam's events and the session's replacement mechanism; no session or agent-loop change exists for it.
+The recovery listener owns selection and retry policy. It reads Session's projected messages, selects the oldest retained input images in request order, and commits the complete selection in one event. Session validates and applies those exact references during append and replay; its shared derivation supplies requests, compaction, and later message rewrites. Token measurement folds the same selections without replacement shadow prices.
 
-No runtime invariant companion is published: Session validates each replacement's surface metadata, and the `compaction/prune` protocol is owned by the compaction seam's companion.
+No runtime invariant companion is published: Session rejects invalid or repeated image references before commit, and this executor retains no separate mutable offload state.
 
 </details>
 
@@ -60,9 +60,9 @@ No runtime invariant companion is published: Session validates each replacement'
 <a id="further-exploration"></a>
 ## Further Exploration
 
-- [Durable image offload by surface replacement](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.md) — the decision this executor implements and the alternatives it replaced.
-- [compaction seam](../compaction/README.md) — the `compaction/prune` shadow-price protocol.
-- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.md) — the sibling executor that trims tool outputs by the same replacement mechanism.
+- [Dedicated image-offload events](../../../.agents/notes/implemented/architecture/2026-09-10-image-offload-events.md) — durable selections, ownership, and rejected alternatives.
+- [compaction seam](../compaction/README.md) — the neighboring summary and text-pruning operations.
+- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.md) — the sibling executor that trims tool outputs while preserving image selections.
 - [dsh-llm](../../llm/llm/README.md) — `ImageBlock.offloaded`, `IMAGE_OFFLOAD_REQUIRED`, and the placeholder projection.
 - [llm-deepseek adapter](../../llm/llm-deepseek/README.md) and [llm-pi-ai adapter](../../llm/llm-pi-ai/README.md) — the route budgets that report offload counts.
 
@@ -75,15 +75,15 @@ No runtime invariant companion is published: Session validates each replacement'
 
 #### What the model sees
 
-Every image occurrence a replacement marked reaches the model as the route's placeholder text (`offloadedImageText`) naming the attachment and its read-only path instead of the image; unmarked occurrences stay images. The set never shrinks on its own, so the model can rely on an offloaded image staying offloaded and read it back through the path when it needs the content again.
+Every selected image occurrence reaches the model as placeholder text (`offloadedImageText`) naming the attachment and its available read-only path; unselected occurrences stay images. Selections persist across requests. A new tool read may introduce a new occurrence of the same attachment without restoring the old one.
 
 #### Token effect
 
-An offloaded occurrence costs its placeholder text instead of visual tokens. The token meter prices the replaced node from the marks on the surface.
+An offloaded occurrence costs its placeholder text instead of visual tokens. The token meter applies the logged selections when pricing each node. Reference-only heuristic counts exclude offload metadata.
 
 #### KV Cache effect
 
-A replacement turns earlier images into placeholder text, so provider cache reuse ends at the first replaced message for that request. Because the replacement never reverts, the prefix stays stable afterwards.
+A selection turns earlier images into placeholder text, so provider cache reuse ends at the first changed image for that request. The selected occurrences remain omitted afterwards.
 
 ## Known Limitations and Deferred Work
 

+ 11 - 11
packages/compaction/compaction-image-offload/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-当较早的图片超出模型路由的预算时,图片密集的会话仍可继续。本插件永久将这些图片替换为注明附件及其只读路径的文本,然后重试请求,不消耗提供方重试预算。后续请求在切换路由、恢复和回放时都保留这一选择。token 计量随替换更新,提供方缓存只能复用到第一条被修改的消息之前。
+当较早的图片超出模型路由的预算时,图片密集的会话仍可继续。本插件永久将这些图片替换为注明附件及其可用只读路径的文本,然后重试,不消耗提供方重试预算。后续请求在切换路由、恢复和回放时都保留这一选择。token 计量随日志记录的选择更新,提供方缓存只能复用到第一条被修改的消息之前。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-凡是运行 agent loop、带有支持图片的路由并挂了 token meter 的组合,都应挂载本插件,随附的 `dsh` 基础配置已经挂载。没有它,`IMAGE_OFFLOAD_REQUIRED` 失败会进入普通恢复并以错误结束该轮次。本插件没有配置:DeepSeek adapter 执行其 file 模式和内联回退预算,pi-ai adapter 执行其 base64 上限,各自上报需要省略的数量。
+凡是运行 agent loop(智能体循环)并带有支持图片的路由的组合,都应挂载本插件,随附的 `dsh` 基础配置已经挂载。没有它,`IMAGE_OFFLOAD_REQUIRED` 失败会进入普通恢复并以错误结束该轮次。本插件没有配置:DeepSeek 适配器执行其 file 模式和内联回退预算,pi-ai 适配器执行其 base64 上限,各自上报需要省略的数量。
 
 ### 最小可用组合
 
@@ -35,7 +35,7 @@ kind: "package-reference"
 
 ### 你可以观察到什么
 
-每次省略会为每个被替换节点追加一条携带该节点启发式 token 价格的 `compaction/prune` 事件,紧接着追加替换节点本身:原消息的 `user/message` 或 `tool/result` 副本,受影响的图片块标了 `offloaded: true`,`surfaceOp` 为 `replace`,`sourceEventSeqs` 指向原节点。原事件原封不动留在日志里。随后是重试的请求;表层变化后 loop 会像任何一次 compaction 之后一样记录新的 `request/header`。
+每次决定追加一条 `image/offload` 事件,通过当前消息事件的序号和消息内深度优先的图片序号指定省略位置。消息事件和表层节点标识保持不变。重试请求前会追加新的 `request/header`,标明新的消息序列。
 
 ### 失败与恢复
 
@@ -49,9 +49,9 @@ kind: "package-reference"
 <details>
 <summary>实现内部——点击展开</summary>
 
-本插件是只有一个 `agent/request-error` 监听器的函数插件。`offloadOldestImages()` 遍历 `session.surface.nodes`,在 `user/message` 和 `tool/result` 节点(含嵌套工具结果)中给前 `offloadImages` 个保留的出现位置打标记,对每个有变化的节点先追加 `compaction/prune` 影子价格,再以 `surfaceOp: { op: 'replace' }` 追加带标记的副本。它复用 compaction seam 的事件和 session 的替换机制,没有为它改动 session 或 agent loop。
+恢复监听器负责选图和重试策略。它读取 Session 派生的消息,按请求顺序选取最早的保留输入图片,并用一条事件提交本次选择。Session 在追加和回放时校验并应用这些确切引用,共享派生结果供请求、压缩(compaction)和后续消息改写使用。token 测量应用同一份选择,不依赖替换影子价格。
 
-本包不发布运行时 invariant 伴生插件:Session 校验每次替换的表层元数据,`compaction/prune` 协议由 compaction seam 的伴生插件负责。
+本包不发布运行时 invariant 伴生插件:Session 在提交前拒绝无效或重复省略的图片引用,执行器不维护独立可变的省略状态。
 
 </details>
 
@@ -60,9 +60,9 @@ kind: "package-reference"
 <a id="further-exploration"></a>
 ## 进一步探索
 
-- [以表层替换实现持久的图片 offload](../../../.agents/notes/implemented/architecture/2026-09-02-durable-image-offload.zh.md)——本执行器实现的决定及其替代的方案。
-- [compaction seam](../compaction/README.zh.md)——`compaction/prune` 影子定价协议。
-- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.zh.md)——用同一替换机制修剪工具输出的兄弟执行器。
+- [独立的图片省略事件](../../../.agents/notes/implemented/architecture/2026-09-10-image-offload-events.zh.md),记录持久选择、职责和被否决的方案。
+- [compaction seam](../compaction/README.zh.md),相邻的摘要和文本剪枝操作。
+- [compaction-tool-result-pruner](../compaction-tool-result-pruner/README.zh.md),保留图片选择并修剪工具输出的兄弟执行器。
 - [dsh-llm](../../llm/llm/README.zh.md)——`ImageBlock.offloaded`、`IMAGE_OFFLOAD_REQUIRED` 与占位投影。
 - [llm-deepseek 适配器](../../llm/llm-deepseek/README.zh.md)与 [llm-pi-ai 适配器](../../llm/llm-pi-ai/README.zh.md)——上报省略数量的路由预算。
 
@@ -75,15 +75,15 @@ kind: "package-reference"
 
 #### 模型看到的内容
 
-替换标记过的每个图片出现位置,以路由的占位文本(`offloadedImageText`)到达模型,文本注明附件及其只读路径而不是图片本身;未标记的出现位置仍是图片。该集合不会自行缩小,模型可以确信被省略的图片会一直被省略,再需要内容时通过路径读回。
+每个选中的图片出现位置以占位文本(`offloadedImageText`)到达模型,文本注明附件及其可用只读路径,未选中的位置仍是图片。选择在后续请求中持续生效。新的工具读取可以引入同一附件的新出现位置,不会恢复旧位置。
 
 #### Token 影响
 
-被省略的出现位置只花费占位文本,不再花费视觉 token。token meter 按表层上的标记为被替换节点定价。
+被省略的出现位置只花费占位文本,不再花费视觉 token。token meter 为每个节点定价时应用日志记录的选择。仅针对引用的启发式计数不计入省略元数据。
 
 #### KV Cache 影响
 
-一次替换把较早的图片换成占位文本,该请求的 provider 缓存复用因此止于第一条被替换的消息。替换永不回退,之后的前缀保持稳定。
+一次选择把较早的图片换成占位文本,该请求的提供方缓存复用因此止于第一张被修改的图片。所选位置之后仍保持省略。
 
 ## 已知限制与延期工作
 

+ 2 - 6
packages/compaction/compaction-image-offload/package.json

@@ -28,10 +28,8 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
-    "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-token-meter": "workspace:^"
+    "@deepseek-ai/dsh-session": "workspace:^"
   },
   "dependencies": {
     "@deepseek-ai/schemastery": "workspace:^"
@@ -41,10 +39,8 @@
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
-    "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^",
-    "@deepseek-ai/dsh-token-meter": "workspace:^"
+    "@deepseek-ai/dsh-session-projection": "workspace:^"
   }
 }

+ 36 - 89
packages/compaction/compaction-image-offload/src/image-offload.ts

@@ -1,98 +1,45 @@
-/**
- * The image offload replacement: mark the oldest retained image occurrences
- * on the surface `offloaded` by replacing each carrying node with a marked
- * copy, priced through the shared `compaction/prune` shadow-price protocol.
- * A replacement never reverts, so every later request and measurement reads
- * the offloaded set from the log.
- *
- * @module @deepseek-ai/dsh-compaction-image-offload/image-offload
- */
-
-import type { Context } from '@deepseek-ai/cordis'
-import type { ContentBlock, Message, ToolResultMessage } from '@deepseek-ai/dsh-llm'
-import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
-import type {} from '@deepseek-ai/dsh-compaction'
-import type {} from '@deepseek-ai/dsh-token-meter'
-
-/** Mark the first `remaining.count` retained occurrences, keeping unchanged blocks by identity. */
-function markOldestImages(blocks: readonly ContentBlock[], remaining: { count: number }): readonly ContentBlock[] {
-  let next: ContentBlock[] | undefined
-  for (const [index, block] of blocks.entries()) {
-    if (remaining.count > 0 && block.type === 'image' && block.offloaded !== true) {
-      remaining.count -= 1
-      next ??= blocks.slice(0, index)
-      next.push({ ...block, offloaded: true })
-      continue
-    }
-    if (remaining.count > 0 && block.type === 'tool-result') {
-      const content = markOldestImages(block.content, remaining)
-      if (content !== block.content) {
-        next ??= blocks.slice(0, index)
-        next.push({ ...block, content: content as ContentBlock[] })
-        continue
-      }
-    }
-    next?.push(block)
-  }
-  return next ?? blocks
-}
+/** Select and log permanent image omissions in current model-request order. */
 
-/** The surface node types that carry model input images. */
-type ImageNode = SessionEvent<'user/message'> | SessionEvent<'tool/result'>
-
-/** The message one image node derives. */
-function messageOf(event: ImageNode): Message {
-  return event.type === 'user/message' ? event.data : event.data.message
-}
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { ImageOffloadTarget, Session } from '@deepseek-ai/dsh-session'
 
 /**
- * Append the shadow price of one node and then its marked copy in place of
- * the original, following the `compaction/prune` protocol so pure consumers
- * subtract the replaced node's heuristic price without per-node state.
- */
-function replaceNode(ctx: Context, session: Session, event: ImageNode, content: readonly ContentBlock[]): void {
-  session.append('compaction/prune', {
-    shadowedRange: { start: event.seq, end: event.seq },
-    shadowedSeqs: [event.seq],
-    shadowedTokenCount: ctx.tokenMeter.estimateMessage(messageOf(event)),
-  })
-  const options = {
-    surfaceOp: { op: 'replace' as const, startSeq: event.seq, endSeq: event.seq },
-    sourceEventSeqs: [event.seq],
-  }
-  if (event.type === 'user/message') {
-    session.append('user/message', { ...event.data, content: content as ContentBlock[] }, options)
-  } else {
-    session.append('tool/result', {
-      ...event.data,
-      message: { ...event.data.message, content: content as ToolResultMessage['content'] },
-    }, options)
-  }
-}
-
-/**
- * Offload the `count` oldest retained image occurrences, in model request
- * order, by replacing each surface node that carries one with a copy whose
- * occurrences are marked `offloaded`. Assistant nodes carry model output, not
- * input images, and are skipped.
- * @param ctx - the plugin context, for the token meter's heuristic price.
- * @param session - the session whose surface the retried request reads.
- * @param count - how many more oldest retained occurrences the adapter needs offloaded.
+ * Record one decision omitting the oldest retained input-image occurrences.
+ * Assistant nodes carry model output and are excluded. Image indexes count
+ * every occurrence, including previously offloaded ones, within each message.
+ * @param session - session whose next request applies the decision.
+ * @param count - additional retained occurrences the adapter needs omitted.
  * @returns whether any occurrence remained to offload.
  */
-export function offloadOldestImages(ctx: Context, session: Session, count: number): boolean {
-  const remaining = { count }
-  let replaced = false
-  for (const seq of [...session.surface.nodes]) {
-    if (remaining.count === 0) break
-    // oxlint-disable-next-line typescript/no-non-null-assertion -- surface nodes index the durable log
+export function offloadOldestImages(session: Session, count: number): boolean {
+  const targets: ImageOffloadTarget[] = []
+  for (const seq of session.surface.nodes) {
+    if (count === 0) break
+    // oxlint-disable-next-line typescript/no-non-null-assertion -- current surface nodes index the durable log
     const event = session.eventAt(seq)!
     if (event.type !== 'user/message' && event.type !== 'tool/result') continue
-    const original = messageOf(event).content
-    const content = markOldestImages(original, remaining)
-    if (content === original) continue
-    replaceNode(ctx, session, event, content)
-    replaced = true
+    // oxlint-disable-next-line typescript/no-non-null-assertion -- both input node types produce a message
+    const message = session.deriveEventMessage(event)!
+    const imageIndexes: number[] = []
+    let imageIndex = 0
+    const visit = (blocks: readonly ContentBlock[]): void => {
+      for (const block of blocks) {
+        if (count === 0) break
+        if (block.type === 'image') {
+          if (block.offloaded !== true) {
+            imageIndexes.push(imageIndex)
+            count -= 1
+          }
+          imageIndex += 1
+        } else if (block.type === 'tool-result') {
+          visit(block.content)
+        }
+      }
+    }
+    visit(message.content)
+    if (imageIndexes.length > 0) targets.push({ seq, imageIndexes })
   }
-  return replaced
+  if (targets.length === 0) return false
+  session.append('image/offload', { targets })
+  return true
 }

+ 6 - 8
packages/compaction/compaction-image-offload/src/index.ts

@@ -1,11 +1,9 @@
 /**
  * Image offload executor for the compaction seam. When an image-capable route
- * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin replaces the
- * surface nodes carrying the named count of oldest retained image occurrences
- * with copies marked `offloaded`, prices each replaced node through the
- * shared `compaction/prune` protocol, and retries the step on the
- * `agent/request-error` waterfall. Every route then sends placeholder text
- * for those occurrences, and the replacement never reverts.
+ * fails a request with `IMAGE_OFFLOAD_REQUIRED`, the plugin records one
+ * `image/offload` decision selecting the oldest retained input occurrences
+ * and retries the step on the `agent/request-error` waterfall. Every route
+ * sends placeholder text for those occurrences in subsequent requests.
  *
  * @module @deepseek-ai/dsh-compaction-image-offload
  */
@@ -17,7 +15,7 @@ import { IMAGE_OFFLOAD_REQUIRED_CODE } from '@deepseek-ai/dsh-llm'
 import { offloadOldestImages } from './image-offload.ts'
 
 export const name = 'compaction-image-offload'
-export const inject = ['agents', 'tokenMeter']
+export const inject = ['agents']
 
 /** The executor has no configuration; image-capable routes own their budgets. */
 export type Config = Readonly<Record<string, never>>
@@ -34,7 +32,7 @@ export function apply(ctx: Context, _config: Config = {}): void {
   ctx.on('agent/request-error', ({ agent, failure }, next): Promise<RequestErrorAction> => {
     if (failure.code !== IMAGE_OFFLOAD_REQUIRED_CODE || failure.offloadImages === undefined) return next()
     // A durable surface repair, not a provider retry: it spends no retry budget and logs no retry event.
-    if (!offloadOldestImages(ctx, agent.session, failure.offloadImages)) return next()
+    if (!offloadOldestImages(agent.session, failure.offloadImages)) return next()
     return Promise.resolve<RequestErrorAction>({ kind: 'retry' })
   })
 }

+ 60 - 35
packages/compaction/compaction-image-offload/tests/image-offload.spec.ts

@@ -1,19 +1,16 @@
 /**
  * Image offload recovery: an adapter's `IMAGE_OFFLOAD_REQUIRED` failure
- * replaces the surface nodes carrying the named count of oldest images with
- * marked copies, each priced by a preceding `compaction/prune` event, and
- * retries the step without a retry event.
+ * records exact image occurrences and retries without replacing messages.
  */
 
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
 import { createAssistantMessage, createToolResultMessage, createUserMessage, IMAGE_OFFLOAD_REQUIRED_CODE, LlmAdapter, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
-import { isReplacementSurfaceEvent, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import { isReplacementSurfaceEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type { Session } from '@deepseek-ai/dsh-session'
-import TokenMeter from '@deepseek-ai/dsh-token-meter'
 import * as offload from '../src/index.ts'
 
 type ScriptEntry = StreamChunk[] | (() => never)
@@ -51,14 +48,19 @@ function offloadRequired(offloadImages: number): () => never {
 
 async function harness(adapter: ScriptedAdapter): Promise<Context> {
   const ctx = new Context()
+  contexts.push(ctx)
   await mountAgentLoopTestDependencies(ctx)
-  await ctx.plugin(TokenMeter)
   await ctx.plugin(Object.assign((inner: Context) => { offload.apply(inner, {}) }, { inject: offload.inject }))
   await ctx.plugin(AgentLoop, { agents: [] })
   ctx.llm.registerAdapter(['mock'], adapter)
   return ctx
 }
 
+const contexts: Context[] = []
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
 function image(name: string): Extract<ContentBlock, { type: 'image' }> {
   return {
     type: 'image',
@@ -85,21 +87,12 @@ function replacements(session: Session): [number, number][] {
     .map(event => [Number(event.sourceEventSeqs?.[0]), Number(event.seq)])
 }
 
-/** Assert each listed replacement is immediately preceded by its `compaction/prune` shadow price. */
-function expectShadowPriced(session: Session, pairs: readonly [number, number][]): void {
-  const events = session.snapshotEvents()
-  for (const [original, replacement] of pairs) {
-    const prune = events[replacement - 1]
-    expect(prune).toMatchObject({
-      type: 'compaction/prune',
-      data: { shadowedRange: { start: original, end: original }, shadowedSeqs: [original] },
-    })
-    expect(prune?.type === 'compaction/prune' ? prune.data.shadowedTokenCount : 0).toBeGreaterThan(0)
-  }
+function decisions(session: Session) {
+  return session.snapshotEvents().filter(event => event.type === 'image/offload')
 }
 
 describe('compaction-image-offload', () => {
-  it('replaces the node carrying the named count of images and retries without a retry event', async () => {
+  it('logs one exact image selection and retries without replacing the message', async () => {
     const adapter = new ScriptedAdapter([offloadRequired(2), textResponse('sent')])
     const ctx = await harness(adapter)
     const agent = await ctx.agentLoop.create(SessionId('offload-required'), { provider: 'mock', model: 'mock' })
@@ -123,14 +116,16 @@ describe('compaction-image-offload', () => {
     const types = events.map(event => event.type)
     expect(types.filter(type => type === 'llm/retry')).toHaveLength(0)
     expect(types.filter(type => type === 'assistant/attempt')).toHaveLength(1)
-    // One node carried both occurrences, so one replacement lands between the failed attempt and the retry.
-    expect(replacements(agent.session)).toHaveLength(1)
-    const [original, replacement] = replacements(agent.session)[0]!
+    expect(replacements(agent.session)).toHaveLength(0)
+    expect(types).not.toContain('compaction/prune')
+    expect(decisions(agent.session)).toHaveLength(1)
+    const decision = decisions(agent.session)[0]!
+    const original = events.find(event => event.type === 'user/message')!.seq
+    expect(decision.data).toEqual({ targets: [{ seq: original, imageIndexes: [0, 1] }] })
     expect(events[original]).toMatchObject({ type: 'user/message', surfaceOp: 'append' })
-    expect(types.indexOf('assistant/attempt')).toBeLessThan(replacement)
-    expect(replacement).toBeLessThan(types.indexOf('assistant/message'))
-    expectShadowPriced(agent.session, replacements(agent.session))
-    // The original event keeps its content; only the replacement carries the marks.
+    expect(types.indexOf('assistant/attempt')).toBeLessThan(decision.seq)
+    expect(decision.seq).toBeLessThan(types.indexOf('assistant/message'))
+    expect(events[decision.seq + 1]).toMatchObject({ type: 'request/header', data: { reason: 'series' } })
     const durable = events[original]!
     expect(durable.type === 'user/message' ? durable.data.content[0] : undefined).not.toHaveProperty('offloaded')
   })
@@ -164,13 +159,13 @@ describe('compaction-image-offload', () => {
 
     expect(adapter.requests).toHaveLength(2)
     expect(offloadedNames(adapter.requests[1]!)).toEqual(['replacement', 'second'])
-    // Both nodes carried one occurrence, so the recovery replaced the replacement node and 'second'.
-    // The first replacement is the test's own; the recovery priced the two it appended.
-    expect(replacements(agent.session).slice(1).map(([original]) => original)).toEqual([3, 2])
-    expectShadowPriced(agent.session, replacements(agent.session).slice(1))
+    expect(replacements(agent.session)).toHaveLength(1)
+    expect(decisions(agent.session).map(event => event.data.targets)).toEqual([[
+      { seq: 3, imageIndexes: [0] }, { seq: 2, imageIndexes: [0] },
+    ]])
   })
 
-  it('replaces a tool result node and leaves blocks after the count untouched', async () => {
+  it('offloads a nested tool-result occurrence and leaves later images untouched', async () => {
     const adapter = new ScriptedAdapter([offloadRequired(1), textResponse('sent')])
     const ctx = await harness(adapter)
     const agent = await ctx.agentLoop.create(SessionId('offload-tool-result'), { provider: 'mock', model: 'mock' })
@@ -205,10 +200,40 @@ describe('compaction-image-offload', () => {
     await agent.whenIdle()
 
     expect(offloadedNames(adapter.requests[1]!)).toEqual(['first'])
-    const [replacement] = replacements(agent.session)
-    expect(replacement).toEqual([Number(result.seq), expect.any(Number) as never])
-    const replaced = agent.session.eventAt(SessionSeq(replacement![1]))!
-    expect(replaced.type === 'tool/result' ? replaced.data.message.source.callId : undefined).toBe(callId)
+    expect(replacements(agent.session)).toHaveLength(0)
+    expect(decisions(agent.session)[0]?.data).toEqual({ targets: [{ seq: result.seq, imageIndexes: [0] }] })
+    expect(agent.session.deriveEventMessage(result)?.source).toEqual(result.data.message.source)
+  })
+
+  it('advances across consecutive failures and preserves the first request snapshot', async () => {
+    const adapter = new ScriptedAdapter([offloadRequired(1), offloadRequired(1), textResponse('sent')])
+    const ctx = await harness(adapter)
+    const agent = await ctx.agentLoop.create(SessionId('offload-repeat'), { provider: 'mock', model: 'mock' })
+    agent.followup(createUserMessage({
+      content: [image('first'), image('second'), image('third')], source: { kind: 'user' },
+    }))
+    await agent.whenIdle()
+    expect(adapter.requests.map(offloadedNames)).toEqual([[], ['first'], ['first', 'second']])
+    expect(decisions(agent.session).map(event => event.data.targets[0]?.imageIndexes)).toEqual([[0], [1]])
+    expect(agent.session.surface.replaceGeneration).toBe(0)
+    expect(agent.session.surface.contentGeneration).toBe(2)
+  })
+
+  it('removes the recovery listener when its plugin is disposed', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await mountAgentLoopTestDependencies(ctx)
+    const fiber = ctx.plugin(offload)
+    await fiber
+    await fiber.dispose()
+    const adapter = new ScriptedAdapter([offloadRequired(1)])
+    ctx.llm.registerAdapter(['mock'], adapter)
+    await ctx.plugin(AgentLoop, { agents: [] })
+    const agent = await ctx.agentLoop.create(SessionId('offload-unloaded'), { provider: 'mock', model: 'mock' })
+    agent.followup(createUserMessage({ content: [image('a')], source: { kind: 'user' } }))
+    await agent.whenIdle()
+    expect(adapter.requests).toHaveLength(1)
+    expect(decisions(agent.session)).toEqual([])
   })
 
   it('leaves every other failure to downstream recovery', async () => {

+ 1 - 3
packages/compaction/compaction-image-offload/tsconfig.json

@@ -12,9 +12,7 @@
     { "path": "../../../vendor/cordis" },
     { "path": "../../../vendor/schemastery" },
     { "path": "../../llm/llm" },
-    { "path": "../../llm/token-meter" },
     { "path": "../../core/session" },
-    { "path": "../../core/agent" },
-    { "path": "../compaction" }
+    { "path": "../../core/agent" }
   ]
 }

+ 2 - 2
packages/compaction/compaction-tool-result-pruner/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/compaction/compaction-tool-result-pruner/README.md
-README.md: a7a41b49966304278b3b3a45babfe6311667e4a5
-README.zh.md: 7e3ba252ffecc366db3af3058bfdc7c4d2e2ab83
+README.md: 7b05154bc02035bb86933e77a60904613f72bbed
+README.zh.md: 06ecb2b3a63f4511cc334878b5c02358c0fad0fd

+ 1 - 1
packages/compaction/compaction-tool-result-pruner/README.md

@@ -41,7 +41,7 @@ With these rows, oversized tool results are trimmed automatically as part of con
 
 ### What gets trimmed
 
-Every tool result whose text exceeds the threshold is replaced by a trimmed version: the configured head, a short "middle pruned" marker, and the configured tail. Rich content such as images and structured blocks keeps its order. The replacement keeps the tool call, step, errors, and metadata — only the text content changes. If a replacement cannot be recorded, the run fails and the trims already applied stay in place.
+Every tool result whose text exceeds the threshold is replaced by a trimmed version: the configured head, a short "middle pruned" marker, and the configured tail. Rich content such as images and structured blocks keeps its order and all logged image-offload selections. The replacement keeps the tool call, step, errors, and metadata — only the text content changes. If a replacement cannot be recorded, the run fails and the trims already applied stay in place.
 
 ### Setting the size limits
 

+ 1 - 1
packages/compaction/compaction-tool-result-pruner/README.zh.md

@@ -41,7 +41,7 @@ kind: "package-reference"
 
 ### 什么会被修剪
 
-每个文本超过阈值的工具结果都会被替换为修剪版本:配置的头部、简短的「middle pruned」标记与配置的尾部。图片与结构化块等富内容保持原有顺序。替换保留工具调用、步骤、错误与元数据——只有文本内容发生变化。如果替换无法被记录,运行会失败,已应用的修剪仍会保留。
+每个文本超过阈值的工具结果都会被替换为修剪版本:配置的头部、简短的「middle pruned」标记与配置的尾部。图片与结构化块等富内容保持原有顺序,并保留日志中的所有图片省略选择。替换保留工具调用、步骤、错误与元数据——只有文本内容发生变化。如果替换无法被记录,运行会失败,已应用的修剪仍会保留。
 
 ### 设置大小限制
 

+ 4 - 3
packages/compaction/compaction-tool-result-pruner/src/index.ts

@@ -144,13 +144,14 @@ export class ToolResultPruner extends Service {
     const pruned: PrunedEntry[] = []
     let charsRemoved = 0
     for (const { seq, event } of candidates) {
-      const result = event.data.message.content[0]
+      const original = session.deriveEventMessage(event) as ToolResultMessage
+      const result = original.content[0]
       const content = this.pruneContent(result.content)
       if (content === null) continue
       const charsBefore = this.measureContent(result.content)
       const charsAfter = this.measureContent(content)
       const message = freezeMessage<ToolResultMessage>({
-        ...event.data.message,
+        ...original,
         content: [{
           ...result,
           content,
@@ -162,7 +163,7 @@ export class ToolResultPruner extends Service {
       session.append('compaction/prune', {
         shadowedRange: { start: seq, end: seq },
         shadowedSeqs: [seq],
-        shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(event.data.message),
+        shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(original),
       })
       const replacement = session.append('tool/result', {
         ...event.data,

+ 19 - 0
packages/compaction/compaction-tool-result-pruner/tests/tool-result-pruner.spec.ts

@@ -5,6 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import SessionStore, {
   Session,
   SessionId,
+  SessionSeq,
 } from '@deepseek-ai/dsh-session'
 import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
 import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
@@ -78,6 +79,24 @@ function appendToolStep(
 }
 
 describe('tool-result pruning configuration', () => {
+  it('preserves a logged image offload when pruning the same result later', () => {
+    const session = Session.create(SessionId('prune-offloaded'))
+    const seq = appendToolStep(session, 1, 'shot', [
+      { type: 'text', text: 'x'.repeat(200) },
+      { type: 'image', attachment: {
+        attachmentId: `sha256:${'a'.repeat(64)}` as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1,
+      } },
+    ])
+    session.append('image/offload', { targets: [{ seq: SessionSeq(seq), imageIndexes: [0] }] })
+    const pruned = service().pruneSession(session)
+    expect(pruned.pruned).toHaveLength(1)
+    const replacement = session.snapshotEvents().at(-1)!
+    expect(replacement.type).toBe('tool/result')
+    expect(JSON.stringify(session.deriveEventMessage(replacement))).toContain('"offloaded":true')
+    expect(JSON.stringify(Session.create(SessionId('restored-offloaded'), session.snapshotEvents()).deriveMessages())).toContain('"offloaded":true')
+    expect(JSON.stringify(session.eventAt(SessionSeq(seq)))).not.toContain('offloaded')
+  })
+
   it('resolves detached immutable defaults and partial overrides', () => {
     const raw = { thresholdChars: 100, headChars: 20, tailChars: 10 }
     const resolved = resolveConfig(raw)

+ 2 - 2
packages/core/agent-loop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
-README.md: cff0eb77b8464d49908bbd7b0e7a1b0658ed2f3c
-README.zh.md: 94fb5406193300abc3077462407fdc8b1dd742a0
+README.md: eaa80c2c8b8511ab5b5bec306a1a6dc3922f799f
+README.zh.md: 5dabc106ff208cb7d93f27d5847f326735164267

文件差异内容过多而无法显示
+ 1 - 1
packages/core/agent-loop/README.md


文件差异内容过多而无法显示
+ 1 - 1
packages/core/agent-loop/README.zh.md


+ 3 - 3
packages/core/agent-loop/src/agent.ts

@@ -99,7 +99,7 @@ export class ReactLoopAgent implements Agent {
     public readonly options: AgentOptions,
     public readonly session: Session,
   ) {
-    this.requestSurfaceGeneration = session.surface.replaceGeneration
+    this.requestSurfaceGeneration = session.surface.contentGeneration
     this.dispatch = agentEvents(loopCtx, this)
     this.scope = createScope(loopCtx, this)
     this.ctx = this.scope.ctx
@@ -364,7 +364,7 @@ export class ReactLoopAgent implements Agent {
       const commits = this.systemPrompt.project(renderedPrompt, {
         inHistory: preparedCall?.systemPromptUpdate === 'in-history',
         startsSeries: startsRequestSeries
-          || this.requestSurfaceGeneration !== this.session.surface.replaceGeneration
+          || this.requestSurfaceGeneration !== this.session.surface.contentGeneration
           || this.toolsChanged(assembly.tools),
       })
       for (const { message, intent } of commits) {
@@ -558,7 +558,7 @@ export class ReactLoopAgent implements Agent {
     signal: AbortSignal,
   ): GenerateOptions {
     const { session } = this
-    const surfaceGeneration = session.surface.replaceGeneration
+    const surfaceGeneration = session.surface.contentGeneration
     const header = canonicalHeader({
       config,
       ...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },

+ 2 - 2
packages/core/session/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/session/README.md
-README.md: 6ed688e475c0b1bc759d6ab8bd84e6537608bb40
-README.zh.md: fcadf48c048d8eba9a9dfb31a098eca27f2ac27b
+README.md: 7b1963955dc936e3c541ef9d06a3e9f039fc0af9
+README.zh.md: 483813cb197d4f3cf87424013e9ad9e7d742756b

+ 6 - 4
packages/core/session/README.md

@@ -49,6 +49,8 @@ session.deriveMessages()         // the derived model history
 
 Surface events (`system/message`, `user/message`, `assistant/message`, `tool/result`) require `surfaceOp` in both typed events and append input. A replacement uses exactly `{ op: 'replace', startSeq, endSeq }`, with inclusive `SessionSeq` endpoints in current surface order. An Assistant message embeds its exact compact provider stream and forbids `sourceEventSeqs`. Known log-only events forbid both metadata fields and never produce a message.
 
+`image/offload` records exact input-image occurrences without creating or replacing a message. Session rejects missing, shadowed, output-image, or already offloaded targets before commit. `deriveMessages()` and the instance `deriveEventMessage()` apply the selections; rewrites preserving images must use these projected messages. Pure reconstructors pass `foldSurface(events).offloadedMessages` to the exported `deriveEventMessage()`.
+
 Append, seed/restore, and event adoption/snapshot reject any `header.system` and exactly empty optional request-header fields (`tools: []`, `adapterDefaults: {}`) instead of normalizing input. Tool-result `data.error` is allowed only when `message.content[0].isError === true`; failure identity remains optional. Rejected appends do not change the log, derived state, or event feed. Adoption validates event-local metadata but not referenced history or replacement membership.
 
 `system/message` holds the rendered system prompt: the first one is surface node 0, the prepared call capability governs admission, with a non-empty rendering consolidated at the first system node on an incapable route or appended after cached history inside a continuing `in-history` series; empty system nodes project to no message, so clearing the prompt requires logged empty replacements of all active system nodes, not just the latest; the surface fold rejects a replacement covering node 0 while it is a `system/message` unless the replacing event is itself a `system/message` over exactly that node, while later system nodes carry no protection and a compaction range may shadow them ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)).
@@ -81,11 +83,11 @@ This section explains how the package realizes the behavior above; the observabl
 
 ### Design concept
 
-The package is built on event sourcing: a `Session` is an append-only log of typed `SessionEvent`s, and everything else — model history, transcripts, telemetry, titles, persistence — derives from that stream. The surface is a derived projection: an incremental manager validates append candidates, advances the ordered view from committed events, and tracks a `replaceGeneration` that bumps on every committed rewrite. Model-visible means logged: anything that reaches a model request must be reconstructable from the log. Each model attempt that reaches settlement commits one event: `assistant/message` carries the assembled model-visible message plus its compact timed stream, while `assistant/attempt` retains a failed, retried, cancelled, or stream-error attempt without adding model history. A hard process loss before settlement leaves no durable attempt stream.
+The package is built on event sourcing: a `Session` is an append-only log of typed `SessionEvent`s, and everything else — model history, transcripts, telemetry, titles, persistence — derives from that stream. The surface is a derived projection: an incremental manager validates append candidates, advances the ordered view from committed events, and tracks `replaceGeneration` for positional replacements and `contentGeneration` for replacements and image-offload decisions. Model-visible means logged: anything that reaches a model request must be reconstructable from the log. Each model attempt that reaches settlement commits one event: `assistant/message` carries the assembled model-visible message plus its compact timed stream, while `assistant/attempt` retains a failed, retried, cancelled, or stream-error attempt without adding model history. A hard process loss before settlement leaves no durable attempt stream.
 
 ### Request headers
 
-`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start or a surface replacement writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail.
+`request/header` stores a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, `change`, or `series`. An explicit message-series start, a surface replacement, or an image-offload decision writes a `series` snapshot when the envelope is unchanged; a simultaneous change uses `startsSeries: true`. Same-series steps, retries, and ordinary later turns inherit the latest snapshot. `adapterDefaults` distinguishes values resolved by the adapter from explicit settings, and `foldRequestHeader()` selects the latest snapshot. This self-contained record supports partial-window rendering and exact reconstruction at the cost of growth per message series; the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md) owns the detail.
 
 ### Source map
 
@@ -105,7 +107,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads,
 
 ### Derived history
 
-`deriveMessages()` caches each surface node's projection once and returns a fresh array per call over shared, deep-frozen messages; each of the four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`) projects its own message kind — the system-role prompt (an empty-content system node projects to no message), user content verbatim, the assembled assistant message with its provider and model, or a user-role tool result. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only. A surface rewrite rebuilds the projection — there is no raw-log fallback, so the surface is the single source of derived history.
+`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; an empty-content system node projects to no message. Image-offload selections mark derived input-image blocks without mutating recorded messages. Replacements and offload decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only.
 
 ### The request header
 
@@ -135,7 +137,7 @@ The package-level contract is enough for most consumers; read these when you nee
 
 #### What the model sees
 
-The model receives the complete messages from `system/message`, `user/message`, `assistant/message`, and `tool/result` surface entries verbatim, the system prompt first — identities, roles, sources, and content blocks are the same values established at creation, and projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their provenance. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message.
+The model receives the complete messages from `system/message`, `user/message`, `assistant/message`, and `tool/result` surface entries with logged image selections applied, the system prompt first. Identities, roles, sources, and unmodified blocks retain their original values; projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their provenance. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message.
 
 #### Token effect
 

+ 6 - 4
packages/core/session/README.zh.md

@@ -49,6 +49,8 @@ session.deriveMessages()         // the derived model history
 
 表层事件(`system/message`、`user/message`、`assistant/message`、`tool/result`)在类型化事件与追加输入中都必须带有 `surfaceOp`。替换操作仅接受 `{ op: 'replace', startSeq, endSeq }`,端点为包含边界的 `SessionSeq`,按当前 surface 顺序解释。assistant 消息会嵌入精确、紧凑的提供方流,并禁止 `sourceEventSeqs`。已知仅日志事件禁止这两个元数据字段,且从不产生消息。
 
+`image/offload` 记录确切的输入图片出现位置,不创建或替换消息。Session 在提交前拒绝缺失、已被遮蔽、属于输出图片或已被省略的目标。`deriveMessages()` 和实例方法 `deriveEventMessage()` 应用这些选择,需要保留图片的改写必须读取这些派生消息。纯重建函数将 `foldSurface(events).offloadedMessages` 传给导出的 `deriveEventMessage()`。
+
 追加、seed/restore 与事件 adoption/snapshot 会拒绝任何 `header.system` 及恰好为空的可选请求头字段(`tools: []`、`adapterDefaults: {}`),而不规范化输入。工具结果的 `data.error` 仅在 `message.content[0].isError === true` 时允许存在;失败标识仍是可选的。被拒绝的追加不会改变日志、派生状态或事件流。Adoption 校验事件局部元数据,但不校验所引用的历史或替换端点是否属于 surface。
 
 `system/message` 承载渲染后的系统提示词:第一条是 surface 第 0 号节点,准入依据已准备调用的能力,不具备能力的路由将非空渲染文本归并到首个系统节点,延续中的 `in-history` 序列则在缓存历史之后追加;空系统节点不投影为消息,因此清除提示词必须为所有生效的系统节点记录空内容替换,而非仅替换最新节点;当第 0 号节点是 `system/message` 时,surface 折叠拒绝覆盖它的替换,除非替换事件本身是恰好覆盖该节点的 `system/message`,而后续系统节点不受保护,压缩范围可以遮蔽它们([决策](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md))。
@@ -81,11 +83,11 @@ session.deriveMessages()         // the derived model history
 
 ### 设计理念
 
-该包建立在事件溯源之上:`Session` 是类型化 `SessionEvent` 的仅追加日志,其他一切——模型历史、transcript(文本记录)、遥测、标题、持久化——都从这条流派生。surface 是派生投影:一个增量管理器校验追加候选、根据已提交事件推进有序视图,并跟踪每次已提交重写都会递增的 `replaceGeneration`。模型可见即已记录:任何到达模型请求的内容都必须能从日志重建。每个完成结算的模型尝试都会提交一个事件:`assistant/message` 携带组装后的模型可见 message 及其紧凑带时间 stream,`assistant/attempt` 则保留失败、重试、取消或 stream error attempt,且不添加模型历史。如果进程在 settlement 前硬中断,则不会留下持久 attempt stream。
+该包建立在事件溯源之上:`Session` 是类型化 `SessionEvent` 的仅追加日志,其他一切——模型历史、transcript(文本记录)、遥测、标题、持久化——都从这条流派生。surface 是派生投影:一个增量管理器校验追加候选、根据已提交事件推进有序视图,通过 `replaceGeneration` 跟踪位置替换,通过 `contentGeneration` 跟踪位置替换和图片省略决定。模型可见即已记录:任何到达模型请求的内容都必须能从日志重建。每个完成结算的模型尝试都会提交一个事件:`assistant/message` 携带组装后的模型可见 message 及其紧凑带时间 stream,`assistant/attempt` 则保留失败、重试、取消或 stream error attempt,且不添加模型历史。如果进程在 settlement 前硬中断,则不会留下持久 attempt stream。
 
 ### 请求头
 
-`request/header` 存储非历史请求封装的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点或表层替换会在请求封装不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。
+`request/header` 存储非历史请求封装的完整规范快照,原因为 `initial`、`resume`、`change` 或 `series`。显式消息序列起点、表层替换或图片省略决定会在请求封装不变时写入 `series` 快照;同时发生变化时使用 `startsSeries: true`。同一序列内的步骤、重试与普通后续轮次继承最新快照。`adapterDefaults` 区分由适配器解析的值与显式设置,`foldRequestHeader()` 选择最新快照。这种自包含记录以每个消息序列增加存储为代价,支持局部窗口渲染与精确重建;细节由[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md)负责。
 
 ### 源码地图
 
@@ -105,7 +107,7 @@ session.deriveMessages()         // the derived model history
 
 ### 派生历史
 
-`deriveMessages()` 把每个 surface 节点的投影缓存一次,每次调用都返回共享、深度冻结消息之上的新数组;四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`)各自投影自己的消息种类——system 角色的提示词(空内容的系统节点投影为无消息)、user 内容原样、带提供方与模型的组装 assistant 消息,或 user 角色的工具结果。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。surface 重写会重建投影——不存在原始日志回退,因此 surface 是派生历史的唯一来源。
+`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的系统节点不派生消息。图片省略选择为派生的输入图片块添加标记,不修改记录的消息。替换和省略决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。
 
 ### 请求头
 
@@ -135,7 +137,7 @@ session.deriveMessages()         // the derived model history
 
 #### 模型看到什么
 
-模型会原样接收 `system/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的完整消息,系统提示词在先——标识、角色、来源与内容块都与创建时确定的值相同,投影从不生成标识。直接提示词与注入上下文仍是彼此独立的 `user/message` 事件,各事件的来源会保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不会添加消息。
+模型会接收 `system/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的消息,并应用日志中的图片省略选择,系统提示词在先。消息标识、角色和来源保持不变,投影不生成标识。直接提示词与注入上下文仍是独立的 `user/message` 事件,各事件的来源保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不添加消息。
 
 #### Token 影响
 

+ 39 - 0
packages/core/session/src/image-offload.ts

@@ -0,0 +1,39 @@
+/** Exact application of logged image selections; selection policy belongs to compaction-image-offload. */
+
+import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
+import { deepFreeze } from '@deepseek-ai/dsh-util-values'
+
+/**
+ * Project selected image occurrences to immutable offloaded blocks.
+ * @param message - message projected before this decision.
+ * @param indexes - nonempty, strictly increasing depth-first image indexes.
+ * @returns an immutable message with the same identity and selected images marked.
+ * @throws when a selected occurrence is missing or already offloaded.
+ */
+export function offloadMessageImages(message: Message, indexes: readonly number[]): Message {
+  let imageIndex = 0
+  let selected = 0
+  const visit = (blocks: readonly ContentBlock[]): ContentBlock[] => {
+    let next: ContentBlock[] | undefined
+    for (const [index, block] of blocks.entries()) {
+      let projected = block
+      if (block.type === 'image') {
+        if (imageIndex === indexes[selected]) {
+          if (block.offloaded === true) throw new Error(`image/offload: image index ${imageIndex} is already offloaded`)
+          projected = { ...block, offloaded: true }
+          selected += 1
+        }
+        imageIndex += 1
+      } else if (block.type === 'tool-result') {
+        const content = visit(block.content)
+        if (content !== block.content) projected = { ...block, content }
+      }
+      if (projected !== block) next ??= blocks.slice(0, index)
+      next?.push(projected)
+    }
+    return next ?? blocks as ContentBlock[]
+  }
+  const content = visit(message.content)
+  if (selected !== indexes.length) throw new Error(`image/offload: image index ${indexes[selected]} does not exist`)
+  return deepFreeze({ ...message, content })
+}

+ 12 - 12
packages/core/session/src/index.ts

@@ -16,7 +16,7 @@ import type { Message } from '@deepseek-ai/dsh-llm'
 import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from './types.ts'
 import type { TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
 import type { CreateSessionOptions, EpochHeader, PrepareSessionOptions, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SessionId, SessionSeedEventState, SurfaceIntent, SurfaceEventType } from './types.ts'
-import { deriveEventMessage, SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts'
+import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } from './surface.ts'
 import type { SessionSurface } from './surface.ts'
 import { foldRequestHeader } from './request-header.ts'
 
@@ -801,7 +801,7 @@ export class Session {
   private derived: Message[] = []
   /** Surface position (nodes projected) the cache has reached. */
   private derivedNodes = 0
-  /** {@link SurfaceManager.replaceGeneration} the cache was built under. */
+  /** {@link SurfaceManager.contentGeneration} the cache was built under. */
   private derivedGeneration = 0
 
   /**
@@ -811,21 +811,21 @@ export class Session {
    * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
    * turn boundary) is correctly absent, and a compaction `replace` deletes the
    * shadowed nodes from the derivation. The projection rules are
-   * {@link deriveEventMessage}, folded per node.
+   * {@link deriveEventMessage}, with logged image-offload selections applied
+   * without changing node membership or message identity.
    *
-   * CACHED: each surface node is projected exactly once, when first seen — a
-   * call costs O(new nodes), and a surface rewrite (a `replace`;
-   * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
+   * CACHED: pure tail growth costs O(new nodes); a replacement or image offload
+   * ({@link SessionSurface.contentGeneration}) rebuilds. The returned array is
    * a fresh snapshot per call (later appends never grow an array a caller
    * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
-   * Their content reuses the already frozen durable event data, so the cache
-   * needs no second deep clone and consumers still cannot mutate the log.
+   * Unchanged content reuses frozen event data; offloaded blocks are frozen
+   * derived copies. Consumers cannot mutate the log through either form.
    * @returns a fresh array of the shared, frozen derived history.
    */
   deriveMessages(): Message[] {
     const surface = this.surface
     const nodes = surface.nodes
-    const generation = surface.replaceGeneration
+    const generation = surface.contentGeneration
     if (generation !== this.derivedGeneration) {
       this.derived = []
       this.derivedNodes = 0
@@ -846,13 +846,13 @@ export class Session {
   }
 
   /**
-   * Instance face of the pure per-node `deriveEventMessage` export from
-   * `surface.ts`.
+   * Project one event with all committed image-offload selections applied.
+   * The original durable event remains unchanged.
    * @param event - the event to project.
    * @returns the derived message, or null when the event produces none.
    */
   deriveEventMessage(event: SessionEvent): Message | null {
-    return deriveEventMessage(event)
+    return this.surfaceManager.deriveEventMessage(event)
   }
 }
 

+ 1 - 0
packages/core/session/src/known-event-types.ts

@@ -40,6 +40,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
   'goal/change',
   'hook/invoked',
   'hook/result',
+  'image/offload',
   'llm/retry',
   'llm/retry-started',
   'model/selection',

+ 82 - 10
packages/core/session/src/surface.ts

@@ -11,6 +11,7 @@
 import type { Message } from '@deepseek-ai/dsh-llm'
 import { SessionLogOffset, SessionSeq } from './types.ts'
 import { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts'
+import { offloadMessageImages } from './image-offload.ts'
 import type {
   SessionEvent,
   SessionSeqCursor,
@@ -79,17 +80,21 @@ export function isReplacementSurfaceEvent(
 /**
  * Project a single event into the LLM message it derives to, or null when it
  * produces none — a non-surface event (attempt, boundary, log-only record) or an
- * empty-content assistant/message (which exists only to host usage). This is
- * THE per-node projection rule: `Session.deriveMessages` folds it over the
- * live surface, external reconstructors and pure projections fold the same
- * function over a log prefix's surface to rebuild the exact messages any
- * request was built from. The returned message is the already frozen message
- * nested in the event wrapper and shared by delivery, durable history, and
- * model requests.
+ * empty-content assistant/message (which exists only to host usage). A caller
+ * reconstructing model input supplies the same prefix's `offloadedMessages`
+ * from {@link foldSurface}; without that map this function reads original
+ * event content. Session instance methods apply the live projection. Messages
+ * are immutable and unchanged content retains its durable identity.
  * @param event - the event to project.
+ * @param offloadedMessages - image projections from the same log prefix's surface fold.
  * @returns the derived message, or null when the event produces none.
  */
-export function deriveEventMessage(event: SessionEvent): Message | null {
+export function deriveEventMessage(
+  event: SessionEvent,
+  offloadedMessages?: ReadonlyMap<SessionSeq, Message>,
+): Message | null {
+  const projected = offloadedMessages?.get(event.seq)
+  if (projected !== undefined) return projected
   // Intentionally non-exhaustive: only message-producing events derive
   // history; turn/step boundaries, failed attempts, and errors are trace/replay
   // data.
@@ -184,6 +189,8 @@ export interface SurfaceFoldResult {
   nodes: SessionSeq[]
   /** Replacement operations in event order. */
   replacements: SurfaceFoldReplacement[]
+  /** Immutable image-offloaded messages, keyed by their original event sequences. */
+  offloadedMessages: ReadonlyMap<SessionSeq, Message>
 }
 
 /** Readonly live projection of the message-producing session events. */
@@ -192,12 +199,16 @@ export interface SessionSurface {
   readonly nodes: readonly SessionSeq[]
   /** Monotonic count of committed positional replacements. */
   readonly replaceGeneration: number
+  /** Monotonic count of committed replacements and image-offload decisions. */
+  readonly contentGeneration: number
 }
 
 /** Mutable state shared by complete and incremental folds. */
 interface SurfaceFoldState {
   nodes: SessionSeq[]
   replaceGeneration: number
+  contentGeneration: number
+  offloadedMessages: Map<SessionSeq, Message>
 }
 
 /** A validated replacement transition that has not mutated fold state yet. */
@@ -211,10 +222,50 @@ interface SurfaceReplacePlan extends SurfaceFoldReplacement {
 type SurfacePlan =
   | { kind: 'append'; seq: SessionSeq }
   | SurfaceReplacePlan
+  | { kind: 'image-offload'; messages: ReadonlyMap<SessionSeq, Message> }
 
 /** Create an empty surface fold state. */
 function createFoldState(): SurfaceFoldState {
-  return { nodes: [], replaceGeneration: 0 }
+  return { nodes: [], replaceGeneration: 0, contentGeneration: 0, offloadedMessages: new Map() }
+}
+
+/** Validate exact image references before publishing any part of the decision. */
+function planImageOffload(
+  state: SurfaceFoldState,
+  event: SessionEvent<'image/offload'>,
+  events: readonly SessionEvent[],
+  baseSeq: SessionLogOffset,
+): Extract<SurfacePlan, { kind: 'image-offload' }> {
+  const data: unknown = event.data
+  if (!isRecord(data) || Object.keys(data).length !== 1 || !Array.isArray(data['targets']) || data['targets'].length === 0) {
+    throw new Error('image/offload: data must contain a nonempty targets array')
+  }
+  const messages = new Map<SessionSeq, Message>()
+  const nodes = new Set(state.nodes)
+  for (const target of data['targets'] as unknown[]) {
+    if (!isRecord(target) || Object.keys(target).length !== 2 || !isEventSeq(target['seq'])
+      || !Array.isArray(target['imageIndexes']) || target['imageIndexes'].length === 0) {
+      throw new Error('image/offload: each target must contain a seq and nonempty imageIndexes')
+    }
+    const seq = target['seq']
+    if (messages.has(seq)) throw new Error(`image/offload: duplicate target seq ${seq}`)
+    if (!nodes.has(seq)) throw new Error(`image/offload: target seq ${seq} is not a current surface node`)
+    const source = events[seq - baseSeq]
+    if (source?.type !== 'user/message' && source?.type !== 'tool/result') {
+      throw new Error(`image/offload: target seq ${seq} must be user/message or tool/result`)
+    }
+    let previous = -1
+    for (const index of target['imageIndexes'] as unknown[]) {
+      if (!isEventSeq(index) || index <= previous) {
+        throw new Error('image/offload: imageIndexes must be strictly increasing non-negative safe integers')
+      }
+      previous = index
+    }
+    const message = state.offloadedMessages.get(seq)
+      ?? (source.type === 'user/message' ? source.data : source.data.message)
+    messages.set(seq, offloadMessageImages(message, target['imageIndexes'] as number[]))
+  }
+  return { kind: 'image-offload', messages }
 }
 
 /** Whether a runtime value is a non-negative safe event sequence. */
@@ -429,6 +480,7 @@ function planSurfaceEvent(
     throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
   }
   const surfaceOp = validateSurfaceMetadata(event)
+  if (event.type === 'image/offload') return planImageOffload(state, event, events, baseSeq)
   if (surfaceOp === undefined) return
   if (surfaceOp === 'append') {
     return { kind: 'append', seq: event.seq }
@@ -468,6 +520,10 @@ function applySurfacePlan(
   } else if (plan?.kind === 'replace') {
     state.nodes.splice(plan.startIdx, plan.endIdx - plan.startIdx + 1, plan.seq)
     state.replaceGeneration += 1
+    state.contentGeneration += 1
+  } else if (plan?.kind === 'image-offload') {
+    for (const [seq, message] of plan.messages) state.offloadedMessages.set(seq, message)
+    state.contentGeneration += 1
   }
   if (plan?.kind !== 'replace') return
   return {
@@ -497,7 +553,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
     )
     if (replacement !== undefined) replacements.push(replacement)
   }
-  return { nodes: [...state.nodes], replacements }
+  return { nodes: [...state.nodes], replacements, offloadedMessages: new Map(state.offloadedMessages) }
 }
 
 /** Incremental ordered surface view and append-boundary validator. */
@@ -540,6 +596,22 @@ export class SurfaceManager implements SessionSurface {
     return this._state.replaceGeneration
   }
 
+  /** Monotonic count of committed changes to existing model-visible content. */
+  get contentGeneration(): number {
+    if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
+    return this._state.contentGeneration
+  }
+
+  /**
+   * Project one message with every committed image-offload decision applied.
+   * @param event - message-producing or log-only event.
+   * @returns its immutable projected message, or null when it produces none.
+   */
+  deriveEventMessage(event: SessionEvent): Message | null {
+    if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()
+    return deriveEventMessage(event, this._state.offloadedMessages)
+  }
+
   /** Surface event sequences in model-visible order. */
   get nodes(): readonly SessionSeq[] {
     if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta()

+ 17 - 0
packages/core/session/src/types.ts

@@ -267,6 +267,15 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change' | 'series'
  * compact raw streams so persistence stores one durable settlement per attempt.
  */
 export interface SessionEventMap {
+  /**
+   * Permanently omit the selected input-image occurrences from subsequent
+   * model requests. Each target names a current user/message or tool/result
+   * node; image indexes are zero-based depth-first positions within that
+   * message, including nested tool results and already offloaded images.
+   * Targets are unique and each index list is nonempty and strictly increasing.
+   * This event changes derived content without replacing message nodes.
+   */
+  'image/offload': { targets: ImageOffloadTarget[] }
   /**
    * Opens turn `turn` before the loop claims queued input or runs pre-step.
    * Rejection, empty input, cancellation, or failure may close it with no
@@ -400,6 +409,14 @@ export interface SessionEventMap {
   'session/end-seed': { inherited?: true }
 }
 
+/** Exact input-image occurrences selected by one durable offload decision. */
+export interface ImageOffloadTarget {
+  /** Current message-producing event containing these occurrences. */
+  seq: SessionSeq
+  /** Zero-based depth-first image indexes within the immutable message. */
+  imageIndexes: number[]
+}
+
 /** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
 export type SessionEventType = keyof SessionEventMap
 

+ 136 - 0
packages/core/session/tests/image-offload.spec.ts

@@ -0,0 +1,136 @@
+import { describe, expect, it } from 'vitest'
+import { createAssistantMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock, ImageBlock } from '@deepseek-ai/dsh-llm'
+import { deriveEventMessage, foldSurface, Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
+
+const image: ImageBlock = {
+  type: 'image',
+  attachment: { attachmentId: `sha256:${'a'.repeat(64)}` as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 },
+}
+
+function input(session: Session, content: ContentBlock[] = [image, image]) {
+  return session.append('user/message', createUserMessage({ content, source: { kind: 'user' } }), { surfaceOp: 'append' })
+}
+
+function marked(session: Session): boolean[] {
+  const result: boolean[] = []
+  const visit = (content: readonly ContentBlock[]): void => {
+    for (const block of content) {
+      if (block.type === 'image') result.push(block.offloaded === true)
+      if (block.type === 'tool-result') visit(block.content)
+    }
+  }
+  for (const message of session.deriveMessages()) visit(message.content)
+  return result
+}
+
+describe('durable image selections', () => {
+  it('preserves message identity, original data, and previously derived snapshots', () => {
+    const session = Session.create(SessionId('images'))
+    const source = input(session, [
+      { type: 'text', text: 'before' }, image,
+      { type: 'tool-result', toolCallId: ToolCallId('nested'), content: [image, { type: 'text', text: 'after' }] },
+      { type: 'tool-result', toolCallId: ToolCallId('text'), content: [{ type: 'text', text: 'unchanged' }] },
+      image,
+    ])
+    const before = session.deriveMessages()
+    session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [1] }] })
+    expect(marked(session)).toEqual([false, true, false])
+    const after = session.deriveMessages()
+    expect(after[0]?.id).toBe(before[0]?.id)
+    expect(after[0]).toBe(session.deriveEventMessage(source))
+    expect(Object.isFrozen(after[0])).toBe(true)
+    expect(Object.isFrozen(after[0]?.content)).toBe(true)
+    expect(after[0]?.content[0]).toBe(before[0]?.content[0])
+    expect(after[0]?.content[3]).toBe(before[0]?.content[3])
+    expect(JSON.stringify(source)).not.toContain('offloaded')
+    expect(JSON.stringify(before)).not.toContain('offloaded')
+    expect(session.surface.nodes).toEqual([source.seq])
+    expect(session.surface.replaceGeneration).toBe(0)
+    expect(session.surface.contentGeneration).toBe(1)
+    expect(session.deriveEventMessage(session.snapshotEvents().at(-1)!)).toBeNull()
+    session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0, 2] }] })
+    expect(marked(session)).toEqual([true, true, true])
+    expect(session.surface.contentGeneration).toBe(2)
+    expect(after[0]).not.toBe(session.deriveMessages()[0])
+  })
+
+  it('reconstructs the same selections through pure folding, resume, restore, and fork', () => {
+    const session = Session.create(SessionId('source'))
+    const source = input(session)
+    const before = session.snapshotEvents()
+    session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })
+    const events = session.snapshotEvents()
+    const fold = foldSurface(events)
+    expect(deriveEventMessage(source, fold.offloadedMessages)).toEqual(session.deriveMessages()[0])
+    expect(marked(Session.create(SessionId('before'), before))).toEqual([false, false])
+    expect(marked(Session.create(SessionId('resume'), events))).toEqual([true, false])
+    const restored = Session.fromRestore(session.id, events, session.header, SessionLogOffset(0), 'shared-frozen')
+    expect(marked(restored)).toEqual([true, false])
+    const childId = SessionId('child')
+    const child = Session.create(childId, events, {
+      ...session.header, id: childId, parentSession: session.id, isSeeded: true,
+    }, SessionLogOffset(events.length))
+    expect(marked(child)).toEqual([true, false])
+    input(child, [image])
+    expect(marked(child)).toEqual([true, false, false])
+    expect(marked(session)).toEqual([true, false])
+  })
+
+  it('counts all images in a tool result without changing its call identity', () => {
+    const session = Session.create(SessionId('tool'))
+    const source = session.append('tool/result', {
+      turn: 1, step: 1,
+      message: createToolResultMessage({ callId: ToolCallId('shot'), isError: false, content: [image, image] }),
+    }, { surfaceOp: 'append' })
+    session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [1] }] })
+    expect(marked(session)).toEqual([false, true])
+    expect(session.deriveEventMessage(source)?.source).toEqual(source.data.message.source)
+  })
+
+  it.each([
+    null, {}, { targets: [] }, { targets: 'bad' }, { targets: [null] },
+    { targets: [{ seq: 0, imageIndexes: [] }] },
+    { targets: [{ seq: 0, imageIndexes: [0], extra: true }] },
+    { targets: [{ seq: 0, imageIndexes: [0] }], extra: true },
+    { targets: [{ seq: -1, imageIndexes: [0] }] },
+    { targets: [{ seq: 0, imageIndexes: [-1] }] },
+    { targets: [{ seq: 0, imageIndexes: [0.5] }] },
+    { targets: [{ seq: 0, imageIndexes: ['0'] }] },
+    { targets: [{ seq: 0, imageIndexes: [1, 0] }] },
+    { targets: [{ seq: 0, imageIndexes: [0, 0] }] },
+    { targets: [{ seq: 0, imageIndexes: [2] }] },
+    { targets: [{ seq: 1, imageIndexes: [0] }] },
+    { targets: [{ seq: 0, imageIndexes: [0] }, { seq: 0, imageIndexes: [1] }] },
+  ])('rejects malformed selections at append and replay without partial application: %j', (data) => {
+    const session = Session.create(SessionId('invalid'))
+    input(session)
+    const events = session.snapshotEvents()
+    const candidate = { type: 'image/offload', seq: SessionSeq(1), time: 0, data } as SessionEvent
+    expect(() => session.append('image/offload', data as SessionEventMap['image/offload'])).toThrow(/image\/offload/)
+    expect(() => Session.create(SessionId('seed'), [...events, candidate])).toThrow(/image\/offload/)
+    expect(() => foldSurface([...events, candidate])).toThrow(/image\/offload/)
+    expect(session.snapshotEvents()).toEqual(events)
+    expect(marked(session)).toEqual([false, false])
+    expect(session.surface.contentGeneration).toBe(0)
+    session.append('image/offload', { targets: [{ seq: SessionSeq(0), imageIndexes: [1] }] })
+    expect(marked(session)).toEqual([false, true])
+  })
+
+  it('rejects repeated offloads and shadowed or assistant targets', () => {
+    const session = Session.create(SessionId('invalid-targets'))
+    const source = input(session)
+    session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })
+    expect(() => session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })).toThrow(/already offloaded/)
+    session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' } }), {
+      surfaceOp: { op: 'replace', startSeq: source.seq, endSeq: source.seq }, sourceEventSeqs: [source.seq],
+    })
+    expect(() => session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [1] }] })).toThrow(/not a current/)
+    const assistant = session.append('assistant/message', {
+      turn: 1, step: 1, stream: [],
+      message: createAssistantMessage({ content: [image], source: { provider: 'mock', model: 'mock' } }),
+    }, { surfaceOp: 'append' })
+    expect(() => session.append('image/offload', { targets: [{ seq: assistant.seq, imageIndexes: [0] }] })).toThrow(/must be user\/message/)
+  })
+})

+ 6 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -4358,6 +4358,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'ImageMediaType',
     declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';',
   },
+  {
+    name: 'ImageOffloadTarget',
+    declaration: 'export interface ImageOffloadTarget {\n    seq: SessionSeq;\n    imageIndexes: number[];\n}',
+  },
   {
     name: 'ImageRequestPolicy',
     declaration: 'export interface ImageRequestPolicy {\n    maxPixels: number;\n    maxBytes: number;\n}',
@@ -5104,7 +5108,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionEventMap',
-    declaration: 'export interface SessionEventMap {\n    \'turn/start\': {\n        turn: number;\n    };\n    \'turn/end\': {\n        turn: number;\n        reason: TurnEndReason;\n    };\n    \'step/start\': {\n        turn: number;\n        step: number;\n    };\n    \'step/end\': {\n        turn: number;\n        step: number;\n    };\n    \'user/message\': UserMessage;\n    \'system/message\': {\n        turn: number;\n        step: number;\n        message: SystemMessage;\n    };\n    \'assistant/message\': {\n        turn: number;\n        step: number;\n        message: AssistantMessage;\n        stream: AssistantStreamRecord[];\n        usage?: TokenUsage;\n        interrupted?: true;\n    };\n    \'assistant/attempt\': {\n        turn: number;\n        step: number;\n        stream: AssistantStreamRecord[];\n    };\n    \'tool/call\': {\n        turn: number;\n        step: number;\n        callId: ToolCallId;\n        name: string;\n        arguments: string;\n    };\n    \'tool/result\': {\n        turn: number;\n        step: number;\n        message: ToolResultMessage;\n        error?: {\n            name: string;\n            code: string;\n        };\n        meta?: JsonValue;\n    };\n    \'request/header\': {\n        header: EpochHeader;\n        reason: RequestHeaderReason;\n        startsSeries?: true;\n    };\n    \'request/context\': RequestContext;\n    \'session/end-seed\': {\n        inherited?: true;\n    };\n}',
+    declaration: 'export interface SessionEventMap {\n    \'image/offload\': {\n        targets: ImageOffloadTarget[];\n    };\n    \'turn/start\': {\n        turn: number;\n    };\n    \'turn/end\': {\n        turn: number;\n        reason: TurnEndReason;\n    };\n    \'step/start\': {\n        turn: number;\n        step: number;\n    };\n    \'step/end\': {\n        turn: number;\n        step: number;\n    };\n    \'user/message\': UserMessage;\n    \'system/message\': {\n        turn: number;\n        step: number;\n        message: SystemMessage;\n    };\n    \'assistant/message\': {\n        turn: number;\n        step: number;\n        message: AssistantMessage;\n        stream: AssistantStreamRecord[];\n        usage?: TokenUsage;\n        interrupted?: true;\n    };\n    \'assistant/attempt\': {\n        turn: number;\n        step: number;\n        stream: AssistantStreamRecord[];\n    };\n    \'tool/call\': {\n        turn: number;\n        step: number;\n        callId: ToolCallId;\n        name: string;\n        arguments: string;\n    };\n    \'tool/result\': {\n        turn: number;\n        step: number;\n        message: ToolResultMessage;\n        error?: {\n            name: string;\n            code: string;\n        };\n        meta?: JsonValue;\n    };\n    \'request/header\': {\n        header: EpochHeader;\n        reason: RequestHeaderReason;\n        startsSeries?: true;\n    };\n    \'request/context\': RequestContext;\n    \'session/end-seed\': {\n        inherited?: true;\n    };\n}',
   },
   {
     name: 'SessionEventMetadataFilter',
@@ -5444,7 +5448,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SessionSurface',
-    declaration: 'export interface SessionSurface {\n    readonly nodes: readonly SessionSeq[];\n    readonly replaceGeneration: number;\n}',
+    declaration: 'export interface SessionSurface {\n    readonly nodes: readonly SessionSeq[];\n    readonly replaceGeneration: number;\n    readonly contentGeneration: number;\n}',
   },
   {
     name: 'SessionSurfaceSnapshot',

+ 2 - 2
packages/llm/llm-deepseek/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
-README.md: 8995c40f0ca9960496ff121f833cd4b143791e01
-README.zh.md: 80f0fc2c5579b36b5abf772d6aa1cfcde26b2fe1
+README.md: 331b9d5350ab69dfeb4ca15671cb62ed4d6646d6
+README.zh.md: 542cb99fb32cbf1418852716663f6d58b2908a76

+ 1 - 1
packages/llm/llm-deepseek/README.md

@@ -156,7 +156,7 @@ The selected DeepSeek model receives the harness system prompt, message history,
 
 #### Token effect
 
-Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence a surface replacement marks offloaded as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees.
+Provider tokenization governs exact text and image-token input. The adapter declares per-route `imageRequestPricing`: it prices each occurrence selected by a logged image-offload decision as its placeholder text and each retained image at its projected dimensions with the published v4 vision accounting (14px patch grid, 3:1 downsampling, 384-token cap, worst-case alignment pad). This lets the token meter price image pressure before a request; reported usage remains authoritative. Reasoning passback carries every reasoned turn's chain of thought into later requests, while offloaded images stop costing visual tokens. A request whose retained occurrences exceed the file-mode or inline-fallback budget (`maxRequestFilesBytes`, `maxImagesPerRequest`, both quanta) at their exact request-version bytes fails with `IMAGE_OFFLOAD_REQUIRED` naming the additional oldest occurrences to offload, and `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries. Cache-read usage is reported when available. `totalTokens` is the exact `prompt_tokens + completion_tokens` aggregate and is omitted if a supplied `total_tokens` disagrees.
 
 #### KV Cache effect
 

+ 1 - 1
packages/llm/llm-deepseek/README.zh.md

@@ -156,7 +156,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留
 
 #### Token 影响
 
-提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把表层替换标记为已省略的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的 v4 视觉计量规则(14 px patch 网格、3:1 降采样、单图 384 token 上限、最坏情况下的对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用带标记的副本替换承载节点并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。
+提供方分词决定精确的文本与图片 token 输入。适配器声明按路由的 `imageRequestPricing`:把日志中的图片省略决策选中的每个出现位置按其占位文本计价,并按投影后的尺寸使用公开的 v4 视觉计量规则(14 px patch 网格、3:1 降采样、单图 384 token 上限、最坏情况下的对齐 pad)为每张保留图片计价。这使 token 计量服务可以在请求发出前为图片压力定价;上报的 usage 仍是权威值。推理回传会把每个推理轮次的思维链带进后续请求,而已省略的图片不再消耗视觉 token。保留的出现位置按精确请求版本字节超过 file 模式或内联回退预算(`maxRequestFilesBytes`、`maxImagesPerRequest` 与两个量子)的请求,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试。可用时报告缓存读取用量。`totalTokens` 是精确的 `prompt_tokens + completion_tokens` 汇总值;提供方给出的 `total_tokens` 不一致时省略该值。
 
 #### KV Cache 影响
 

+ 2 - 2
packages/llm/llm-pi-ai/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
-README.md: bcd37db5a2d42b23ac2d88c54d129876cdae230e
-README.zh.md: 8a6ee2806f995aeb5e909cd2b585cd861afd10e7
+README.md: a49281b5ddc63c1fe4ce628857ff6f267dc498cd
+README.zh.md: 78aed7db98e217691351f49f3f8ee41e74177e17

+ 2 - 2
packages/llm/llm-pi-ai/README.md

@@ -180,7 +180,7 @@ Read these pages when the package-level contract is not enough. They move from t
 
 #### What the model sees
 
-The selected catalog model receives one system prompt (`GenerateOptions.system`, otherwise the text of a leading `system` history message; a leading system message with empty text sends none), the remaining history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence a surface replacement marks offloaded keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` replaces the carrying surface nodes with marked copies and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
+The selected catalog model receives one system prompt (`GenerateOptions.system`, otherwise the text of a leading `system` history message; a leading system message with empty text sends none), the remaining history, tools, and sampling fields supported by pi-ai's common streaming API. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content.
 
 #### Token effect
 
@@ -188,7 +188,7 @@ Provider tokenization governs exact input. Retained images add the stable attach
 
 #### KV Cache effect
 
-Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An offload replacement turns an earlier image into placeholder text, so reuse ends at that message; the replacement never reverts, so the prefix stays stable afterwards.
+Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An offload decision turns an earlier image into placeholder text, so reuse ends at that message; the omission never reverts, so the prefix stays stable afterwards.
 
 ### Provider response
 

+ 2 - 2
packages/llm/llm-pi-ai/README.zh.md

@@ -180,7 +180,7 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或
 
 #### 模型看到什么
 
-所选目录模型会收到一条系统提示词(`GenerateOptions.system`,否则取历史中首条 `system` 消息的文本;首条 system 消息文本为空时不发送系统提示词)、其余历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。表层替换标记为已卸载的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用带标记的副本替换承载节点并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。
+所选目录模型会收到一条系统提示词(`GenerateOptions.system`,否则取历史中首条 `system` 消息的文本;首条 system 消息文本为空时不发送系统提示词)、其余历史、工具与 pi-ai 通用流式 API 支持的采样字段。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。
 
 #### Token 影响
 
@@ -188,7 +188,7 @@ Settings 写入会在合并组合层与用户层后严格校验每个新增或
 
 #### KV Cache 影响
 
-转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略替换会把较早图片换成占位文本,因此复用在该消息处结束;替换永不回退,此后前缀保持稳定。
+转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。
 
 ### 提供方响应
 

+ 2 - 2
packages/llm/llm/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm/README.md
-README.md: 0817cf64a65dbd1d4aad8873470c961b691a9cd1
-README.zh.md: f28fe85818cb95a3d2576f3a37efa9cb58c1e3ee
+README.md: 8e4e547190a039959f7db29898945f2ed68ff874
+README.zh.md: 9164f4a6e6107e439b732539cddeb14716bc3fdc

文件差异内容过多而无法显示
+ 0 - 0
packages/llm/llm/README.md


+ 2 - 2
packages/llm/llm/README.zh.md

@@ -101,7 +101,7 @@ for await (const chunk of ctx.llm.stream({
 
 ### 主流程
 
-请求会对照其精确模型的能力校验,包括上下文窗口、输出默认值、推理强度、输入模态与 `systemPromptUpdate` 模式,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR(热模块替换)或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 句柄文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。表层替换标为 `offloaded: true` 的图片出现位置,经 `projectOffloadedImages()` 以占位文本到达每条路由。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-compaction-image-offload` 用带标记的副本替换承载这些图片的表层节点并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall(瀑布式事件),随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。
+请求会对照其精确模型的能力校验,包括上下文窗口、输出默认值、推理强度、输入模态与 `systemPromptUpdate` 模式,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR(热模块替换)或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 句柄文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。派生后带有 `offloaded: true` 的图片出现位置,经 `projectOffloadedImages()` 以占位文本到达每条路由。支持图片的路由在保留的出现位置按精确字节超过其 `LlmImageRequestBudget` 时,以 `IMAGE_OFFLOAD_REQUIRED` 失败并说明还需省略多少最老的出现位置(`requiredImageOffload()`),绝不发送未记录的投影;`dsh-compaction-image-offload` 用一条 `image/offload` 事件记录所选位置并重试。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall(瀑布式事件),随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。
 
 文件检测在每次请求时读取当前内容,包括嵌套工具结果,不缓存消息身份或冻结状态。[文件扫描决策](../../../.agents/notes/implemented/simplification/2026-09-07-file-content-scan.zh.md)记录了实测遍历成本。
 
@@ -141,7 +141,7 @@ for await (const chunk of ctx.llm.stream({
 
 #### KV Cache 影响
 
-推理强度的具体化会保留已组装请求前缀。图片身份与请求预览文本是确定性的,可选执行世界路径则按请求解析;路径变化或一次省略替换可能从该图片起阻止复用。
+推理强度的具体化会保留已组装请求前缀。图片身份与请求预览文本是确定性的,可选执行世界路径则按请求解析;路径变化或一次省略决策可能从该图片起阻止复用。
 
 ## 已知限制与延期工作
 

+ 5 - 6
packages/llm/llm/src/types.ts

@@ -51,9 +51,8 @@ export interface LlmFailure {
   /**
    * With code `IMAGE_OFFLOAD_REQUIRED`: how many more of the oldest retained
    * image occurrences the route needs offloaded before the same request fits
-   * its exact byte accounting. `dsh-compaction-image-offload` replaces the
-   * surface nodes carrying that many oldest retained occurrences with copies
-   * marked `offloaded` and retries the step.
+   * its exact byte accounting. `dsh-compaction-image-offload` records the
+   * selected occurrences in an `image/offload` event and retries the step.
    */
   readonly offloadImages?: number
 }
@@ -81,9 +80,9 @@ export interface ImageBlock {
   /** Immutable bytes and intrinsic display metadata owned by the attachment service. */
   attachment: ImageAttachmentRef
   /**
-   * Set on a surface replacement once the occurrence is offloaded from
-   * requests: every route sends its placeholder text, which names the image
-   * and its read-only path, instead of the image.
+   * Derived from a durable image-offload decision or preserved by a message
+   * rewrite. Every route sends placeholder text naming the image and its
+   * available read-only path instead of image bytes.
    */
   offloaded?: true
 }

+ 2 - 2
packages/llm/token-meter/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
-README.md: f5ad725ec8e1a614da2ad3ea95813acd85d4a5c8
-README.zh.md: 3ed307eaa58dd4f5e5ee3981a06e0c6b487d70a0
+README.md: 37b9614a6c684a27d44b8b8bb65108029d58612f
+README.zh.md: f2fb79132ec7deded9051a85db90cc1995a5f336

+ 4 - 2
packages/llm/token-meter/README.md

@@ -40,7 +40,7 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session)
 const price = ctx.tokenMeter.estimateMessage(message)
 ```
 
-Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing, and occurrences a surface replacement marked `offloaded` are priced as the route's placeholder text exactly as the surface sends them; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements.
+Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing, and occurrences selected by an `image/offload` event are priced as the route's placeholder text exactly as the surface sends them; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements.
 
 The measurement anchor includes the priced surface immediately before the successful `assistant/message`, including system and user messages admitted after `step/start` and replacements made before a retry. With unchanged durable output, the completed call has zero surface delta: its prompt is already included in provider usage. Later surface changes remain signed deltas against that anchor.
 
@@ -48,6 +48,8 @@ The measurement anchor includes the priced surface immediately before the succes
 
 When the composition provides `ctx.sessionProjections`, token-meter registers three projection units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys.
 
+Image offload reprices existing node identities while preserving prior usage anchors. The fixed reference heuristic excludes `offloaded` metadata, so an offload decision does not change `contextBreakdown` or the scalar heuristic total; route-aware measurement replaces the selected visual prices with placeholder text.
+
 `contextBreakdown` classifies the last nonempty surviving `system/message` in surface order as `systemTokens`; empty dormant nodes contribute nothing, and no nonempty system means zero. `messageTokens` includes every other visible node, including superseded prompts. Their sum always equals `measure().nodes[].heuristicTokens`, including after unmetered replacements, compaction, and per-node prompt clearing. `toolsTokens` follows the latest `request/header`. All three use the fixed heuristic, not route image pricing or file-handle projection; they are approximate composition, not billing or `projectedTokens`.
 
 `deriveTurnTokenUsage(events)` folds one complete turn into exact per-attempt and whole-turn usage for browser consumers. It returns no result when lifecycle evidence is missing, counts are unsafe, or exact totals conflict; each corresponding aggregate appears only when every participating attempt reports its optional cache, reasoning, or route value.
@@ -98,7 +100,7 @@ Each `measure()` call synchronizes the fold to the current durable tail, then re
 
 ### Projection semantics
 
-`contextBreakdown` retains plain-JSON `{ seq, heuristicTokens, system }` entries in surface order and reuses the measurement plan/commit fold. Its state and surface transitions are O(current retained surface), not O(1) and not O(total historical log); replaced entries and message bodies are not retained. State version 4 invalidates scalar checkpoints and replays the log. `contextPressure` remains the scalar shadow-price consumer: replacements without adjacent claims contribute zero delta. The usage fold retains one last-sample slot because legal logs never report usage for an earlier step after a later step reports usage.
+`contextBreakdown` retains plain-JSON `{ seq, heuristicTokens, system }` entries in surface order and reuses the measurement plan/commit fold. Its state and surface transitions are O(current retained surface), not O(1) and not O(total historical log); replaced entries and message bodies are not retained. State version 5 invalidates checkpoints priced with offload metadata. `contextPressure` remains the scalar shadow-price consumer: replacements without adjacent claims contribute zero delta. The usage fold retains one last-sample slot because legal logs never report usage for an earlier step after a later step reports usage.
 
 </details>
 

+ 4 - 2
packages/llm/token-meter/README.zh.md

@@ -40,7 +40,7 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session)
 const price = ctx.tokenMeter.estimateMessage(message)
 ```
 
-每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本,表层替换标为已省略的出现位置则按路由的占位文本计价,与表层实际发送的一致;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 句柄文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。
+每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本,`image/offload` 事件选中的出现位置则按路由的占位文本计价,与表层实际发送的一致;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 句柄文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。
 
 测量锚点包含成功的 `assistant/message` 之前的已计价表面,包括 `step/start` 之后接纳的系统与用户消息,以及重试之前执行的替换。持久输出未变时,完成调用的表面增量为零:其提示词已包含在提供方用量中。后续表面变更仍是相对于该锚点的带符号增量。
 
@@ -48,6 +48,8 @@ const price = ctx.tokenMeter.estimateMessage(message)
 
 当组合提供 `ctx.sessionProjections` 时,token-meter 注册三个投影单元。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。
 
+图片省略重新计算现有节点的价格,同时保留此前的用量锚点。固定引用启发式规则不计入 `offloaded` 元数据,因此一次省略决定不改变 `contextBreakdown` 或标量启发式总量,按路由的测量则把所选图片的视觉价格换成占位文本价格。
+
 `contextBreakdown` 把 surface 顺序中最后一个非空且存活的 `system/message` 归入 `systemTokens`;休眠的空节点不贡献 token,没有非空系统消息时为零。`messageTokens` 包含其余所有可见节点,包括被取代的提示词。两者之和始终等于 `measure().nodes[].heuristicTokens`,未计量替换、压缩和逐节点清空提示词之后也成立。`toolsTokens` 跟随最新 `request/header`。三个数字都使用固定启发式规则,而非路由图片定价或文件句柄投影;它们是近似构成,不是计费数据或 `projectedTokens`。
 
 `deriveTurnTokenUsage(events)` 为浏览器消费方把一个完整轮次折叠为精确的逐次尝试与整轮用量。生命周期证据缺失、计数不安全或精确总量矛盾时不返回结果;只有每次参与的尝试都报告可选缓存、推理或路由值时,相应汇总才会出现。
@@ -98,7 +100,7 @@ const price = ctx.tokenMeter.estimateMessage(message)
 
 ### 投影语义
 
-`contextBreakdown` 按 surface 顺序保留纯 JSON 的 `{ seq, heuristicTokens, system }` 条目,并复用测量服务的 plan/commit fold。其状态与 surface 转换成本为 O(当前保留 surface),不是 O(1),也不是 O(完整历史日志);被替换条目和消息正文不保留。状态版本 4 使标量检查点失效并重放日志。`contextPressure` 仍是标量影子价消费方:没有相邻 claim 的替换贡献零增量。用量 fold 保留一个最后样本槽,因为合法日志不会在更晚步骤报告用量后再次报告更早步骤的用量。
+`contextBreakdown` 按 surface 顺序保留纯 JSON 的 `{ seq, heuristicTokens, system }` 条目,并复用测量服务的 plan/commit fold。其状态与 surface 转换成本为 O(当前保留 surface),不是 O(1),也不是 O(完整历史日志);被替换条目和消息正文不保留。状态版本 5 使计入省略元数据的检查点失效。`contextPressure` 仍是标量影子价消费方:没有相邻 claim 的替换贡献零增量。用量 fold 保留一个最后样本槽,因为合法日志不会在更晚步骤报告用量后再次报告更早步骤的用量。
 
 </details>
 

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

@@ -47,7 +47,7 @@ type ContextBreakdownState = z.infer<typeof contextBreakdownStateSchema>
  */
 export const contextBreakdownProjectionDefinition = {
   key: 'contextBreakdown',
-  stateVersion: 4,
+  stateVersion: 5,
   stateSchema: contextBreakdownStateSchema,
   init: (): ContextBreakdownState => ({
     nodes: [],

+ 6 - 1
packages/llm/token-meter/src/estimate.ts

@@ -21,11 +21,16 @@ export const ROLE_OVERHEAD = 4
 /**
  * Structural JSON price of one block outside the typed pricing arms: the
  * fixed heuristic for merge-extended blocks and for image references, whose
- * request price is route-owned rather than fixed.
+ * request price is route-owned rather than fixed. Image offload marks do not
+ * change this reference-only heuristic; route pricing owns their placeholders.
  * @param block - block to price without mutation.
  * @returns heuristic tokens for the block's JSON structure.
  */
 export function estimateStructuralBlock(block: ContentBlock): number {
+  if (block.type === 'image') {
+    const { offloaded: _offloaded, ...reference } = block
+    return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(reference).length / CHARS_PER_TOKEN)
+  }
   return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
 }
 

+ 12 - 0
packages/llm/token-meter/src/index.ts

@@ -248,6 +248,18 @@ export class TokenMeter extends Service {
     let nextAnchor = state.anchor
 
     switch (event.type) {
+      case 'image/offload': {
+        const offloaded = new Map(event.data.targets.map(target => [target.seq, new Set(target.imageIndexes)]))
+        state.surface = state.surface.map((node) => {
+          const indexes = offloaded.get(node.seq)
+          if (indexes === undefined) return node
+          return {
+            ...node,
+            images: node.images.map((image, index) => (indexes.has(index) ? { ...image, offloaded: true as const } : image)),
+          }
+        })
+        break
+      }
       case 'request/header':
         nextHeader = canonicalHeader(event.data.header)
         break

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

@@ -172,7 +172,7 @@ export const tokenUsageProjectionDefinition = {
  */
 export const contextPressureProjectionDefinition = {
   key: 'contextPressure',
-  stateVersion: 4,
+  stateVersion: 5,
   stateSchema: contextPressureStateSchema,
   init: () => ({ surfaceTokens: 0 }),
   apply: (state, event) => {

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

@@ -405,7 +405,7 @@ describe('contextBreakdown session projection', () => {
       ctx.sessionProjections.checkpoint(session),
     )) as ReturnType<typeof ctx.sessionProjections.checkpoint>
     const row = checkpoint['contextBreakdown']!
-    expect(row.ver).toBe(4)
+    expect(row.ver).toBe(5)
     expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual(projected(ctx, session))
     const replacement = session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' },
@@ -424,7 +424,7 @@ describe('contextBreakdown session projection', () => {
       stale, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount,
     )
     expect(replayed.snapshot.values.contextBreakdown).toEqual(projected(ctx, session))
-    expect(replayed.checkpoint['contextBreakdown']?.ver).toBe(4)
+    expect(replayed.checkpoint['contextBreakdown']?.ver).toBe(5)
     const invalid = {
       ...checkpoint,
       contextBreakdown: {
@@ -458,7 +458,7 @@ describe('contextBreakdown session projection', () => {
         systemTokens: 8, toolsTokens: staleValue.toolsTokens, messageTokens: 9,
       })
       expect(restored.checkpoint).toEqual(current)
-      expect(restored.checkpoint['contextBreakdown']?.ver).toBe(4)
+      expect(restored.checkpoint['contextBreakdown']?.ver).toBe(5)
     } finally {
       await ctx.fiber.dispose()
     }

+ 40 - 6
packages/llm/token-meter/tests/route-pricing.spec.ts

@@ -202,14 +202,14 @@ describe('request projection pricing', () => {
     expect(unknownRoute.nodes[0]!.tokens).toBe(estimateMessage(message))
   })
 
-  it('prices an occurrence a surface replacement marked offloaded as the route placeholder', async () => {
+  it('reprices logged offloads without changing surface node identities or heuristic totals', async () => {
     const placeholder = '[offloaded]'
     const markedPricing: LlmImageRequestPricing = {
       priceImages: images => images.map(block => (block.offloaded === true
         ? { visualTokens: 0, text: placeholder }
         : { visualTokens: VISUAL_TOKENS, text: HANDLE_TEXT })),
     }
-    const { meter, session } = await harness(() => markedPricing)
+    const { ctx, meter, session } = await harness(() => markedPricing)
     session.append('turn/start', { turn: 1 })
     const older = imageMessage('older')
     const newer = imageMessage('newer')
@@ -219,15 +219,18 @@ describe('request projection pricing', () => {
     const before = meter.measure(session)
     expect(before.nodes.map(node => node.tokens)).toEqual([routedMessageTokens(older), routedMessageTokens(newer)])
 
-    session.append('user/message', {
-      ...older,
-      content: older.content.map(block => (block.type === 'image' ? { ...block, offloaded: true as const } : block)),
-    }, { surfaceOp: { op: 'replace', startSeq: olderSeq, endSeq: olderSeq }, sourceEventSeqs: [olderSeq] })
+    session.append('image/offload', { targets: [{ seq: olderSeq, imageIndexes: [0] }] })
     const after = meter.measure(session)
     const imageFree = estimateMessage({ ...older, content: older.content.filter(block => block.type !== 'image') })
     expect(after.nodes[0]!.tokens).toBe(imageFree + estimateContent([{ type: 'text', text: placeholder }]))
     expect(after.nodes[1]!.tokens).toBe(routedMessageTokens(newer))
     expect(after.totalTokens).toBeLessThan(before.totalTokens)
+    expect(after.nodes.map(node => node.seq)).toEqual(before.nodes.map(node => node.seq))
+    expect(after.nodes.map(node => node.heuristicTokens)).toEqual(before.nodes.map(node => node.heuristicTokens))
+    const breakdown = ctx.sessionProjections.snapshot(session).values.contextBreakdown
+    expect(breakdown?.messageTokens).toBe(after.nodes.reduce((total, node) => total + node.heuristicTokens, 0))
+    const restored = Session.create(SessionId('offloaded-restored'), session.snapshotEvents())
+    expect(meter.measure(restored).surfaceTokens).toBe(after.surfaceTokens)
   })
 
   it('fails loud when a route answers a mismatched occurrence count', async () => {
@@ -239,6 +242,37 @@ describe('request projection pricing', () => {
       .toThrow('route image pricing answered 0 prices for 1 occurrences')
   })
 
+  it('offloads one of two equal attachments without rewriting the prior usage anchor', async () => {
+    const placeholder = '[offloaded]'
+    const { ctx, meter, session } = await harness(() => ({
+      priceImages: images => images.map(block => block.offloaded === true
+        ? { visualTokens: 0, text: placeholder }
+        : { visualTokens: VISUAL_TOKENS, text: HANDLE_TEXT }),
+    }))
+    try {
+      const ref = imageRef('same')
+      const source = session.append('user/message', createUserMessage({
+        source: { kind: 'user' },
+        content: [{ type: 'image', attachment: ref }, { type: 'image', attachment: ref }],
+      }), { surfaceOp: 'append' })
+      appendSuccessfulCall(session, header('vision'), { inputTokens: 5000, outputTokens: 10 })
+      const before = meter.measure(session)
+      session.append('image/offload', { targets: [{ seq: source.seq, imageIndexes: [0] }] })
+      const after = meter.measure(session)
+      const saving = VISUAL_TOKENS + estimateContent([{ type: 'text', text: HANDLE_TEXT }])
+        - estimateContent([{ type: 'text', text: placeholder }])
+      expect(after.baseline).toEqual(before.baseline)
+      expect(after.surfaceDeltaTokens - before.surfaceDeltaTokens).toBe(-saving)
+      expect(after.totalTokens).toBe(before.totalTokens - saving)
+      expect(after.nodes[0]?.heuristicTokens).toBe(before.nodes[0]?.heuristicTokens)
+      const projection = session.deriveMessages()[0]!
+      expect(projection.content).toEqual([{ type: 'image', attachment: ref, offloaded: true }, { type: 'image', attachment: ref }])
+      expect(estimateMessage(projection)).toBe(after.nodes[0]?.heuristicTokens)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('prices nested tool-result images through the same route pricing', async () => {
     const { meter, session } = await harness(() => fixedPricing)
     const nested = createUserMessage({

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

@@ -439,7 +439,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(4)
+    expect(checkpoint.contextPressure?.ver).toBe(5)
 
     await meterFiber.dispose()
     expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure')

+ 0 - 6
pnpm-lock.yaml

@@ -4703,9 +4703,6 @@ importers:
       '@deepseek-ai/dsh-agent-loop-testkit':
         specifier: workspace:^
         version: link:../../test-support/agent-loop-testkit
-      '@deepseek-ai/dsh-compaction':
-        specifier: workspace:^
-        version: link:../compaction
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
@@ -4715,9 +4712,6 @@ importers:
       '@deepseek-ai/dsh-session-projection':
         specifier: workspace:^
         version: link:../../session/session-projection
-      '@deepseek-ai/dsh-token-meter':
-        specifier: workspace:^
-        version: link:../../llm/token-meter
 
   packages/compaction/compaction-tool-result-pruner:
     dependencies:

+ 43 - 0
scripts/fixtures/python-snapshot-image-offload.mjs

@@ -0,0 +1,43 @@
+/** One admitted image and one authored failure for the Python SDK event snapshot. */
+
+/** Cordis fixture identity. */
+export const name = 'python-snapshot-image-offload'
+
+/** Admission and request hooks supplied by the shipped SDK profile. */
+export const inject = ['attachments', 'agents', 'llm']
+
+/**
+ * @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local host context.
+ * @param {{ parentSessionId: string }} config - The one session receiving the authored failure.
+ */
+export function apply(ctx, config) {
+  let injected = false
+  let failed = false
+  ctx.on('agent/pre-step', async ({ agent }, next) => {
+    const decision = await next()
+    if (agent.id !== config.parentSessionId || injected || decision.kind === 'reject') return decision
+    const content = await ctx.attachments.admitPromptContent([{
+      type: 'image',
+      mediaType: 'image/png',
+      data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC',
+    }])
+    injected = true
+    return {
+      ...decision,
+      messages: [...decision.messages, { id: 'python-snapshot-image', role: 'user', content, source: { kind: 'plugin', plugin: name } }],
+    }
+  })
+  ctx.on('llm/stream', async function* (options, next) {
+    if (options.sessionId !== config.parentSessionId || options.purpose !== undefined || failed) {
+      yield* next()
+      return
+    }
+    failed = true
+    yield {
+      type: 'finish',
+      reason: { kind: 'error', failure: {
+        code: 'IMAGE_OFFLOAD_REQUIRED', message: 'authored image budget failure', offloadImages: 1,
+      } },
+    }
+  })
+}

+ 9 - 0
scripts/smoke-python-runtime.py

@@ -1290,6 +1290,9 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
         sessions = dsh_home / "sessions"
         patch = write_advanced_profile_patch(root, "snapshot.patch.yml", sessions)
         feedback_patch = write_profile_patch(root, "feedback.patch.yml", sessions, [{"insert": [
+            {"id": "snapshot-image-offload", "name": (
+                Path(__file__).resolve().parent / "fixtures/python-snapshot-image-offload.mjs"
+            ).as_uri(), "config": {"parentSessionId": SNAPSHOT_SESSION_ID}},
             {"id": "snapshot-workflow-order", "name": (
                 Path(__file__).resolve().parent / "fixtures/python-snapshot-workflow-order.mjs"
             ).as_uri(), "config": {
@@ -1319,6 +1322,12 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
             result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID)
 
         assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response
+        offloads = [event for event in result.events if event.get("type") == "image/offload"]
+        if len(offloads) != 1 or "surfaceOp" in offloads[0]:
+            raise AssertionError(f"advanced snapshot expected one standalone image offload: {offloads}")
+        targets = offloads[0]["data"]["targets"]
+        if len(targets) != 1 or targets[0]["imageIndexes"] != [0]:
+            raise AssertionError(f"advanced snapshot selected unexpected image occurrences: {targets}")
         feedback_types = [event.get("type") for event in result.events
                           if str(event.get("type")).startswith("feedback/")]
         if feedback_types != ["feedback/record", "feedback/record", "feedback/message-put", "feedback/message-put", "feedback/message-delete"]:

文件差异内容过多而无法显示
+ 321 - 93
scripts/snapshots/python-sdk-single-exe/advanced/result.json


文件差异内容过多而无法显示
+ 5 - 1
scripts/snapshots/python-sdk-single-exe/advanced/session.v3.jsonl


+ 5 - 0
scripts/type-equiv.manifest.json

@@ -1,6 +1,11 @@
 {
   "comment": "Maps each primary ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Paired `.zh.md` blocks are byte-identical derivatives checked through their unsuffixed sibling and have no duplicate entry. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence between primary blocks and entries. Add an entry when you add a primary source-equivalence block; remove it when you remove the block.",
   "entries": [
+    {
+      "doc": "docs/subsystems/session.md",
+      "symbol": "ImageOffloadTarget",
+      "source": "packages/core/session/src/types.ts"
+    },
     {
       "doc": "docs/subsystems/core.md",
       "symbol": "Branded",

+ 3 - 4
snapshots/sdk/inline-image-prompt/session.v3.jsonl

@@ -12,10 +12,9 @@
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}}
 {"type":"session/title","data":{"title":"Inspect these images, then reply","messageSeqs":[8],"source":{"kind":"fallback"}}}
-{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1788953601518,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}}
-{"type":"compaction/prune","data":{"shadowedRange":{"start":8,"end":8},"shadowedSeqs":[8],"shadowedTokenCount":318}}
-{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect these images, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69},"offloaded":true},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"sourceEventSeqs":[8],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":8}}
+{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":1789029408176,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"DeepSeek base64 request images exceed the route budget; 3 more oldest occurrence(s) must be offloaded.","code":"IMAGE_OFFLOAD_REQUIRED","offloadImages":3}}}}]}}
+{"type":"image/offload","data":{"targets":[{"seq":8,"imageIndexes":[0,1,2]}]}}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"series"}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788953601528,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1788953601528,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788953601528,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788953601528,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1789029408184,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1789029408184,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1789029408184,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1789029408184,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}

部分文件因为文件数量过多而无法显示