diff --git a/lib/hooks.ts b/lib/hooks.ts
index e31cf110..86b7bb13 100644
--- a/lib/hooks.ts
+++ b/lib/hooks.ts
@@ -124,7 +124,10 @@ export function createChatMessageTransformHandler(
stripHallucinations(output.messages)
cacheSystemPromptTokens(state, output.messages)
- assignMessageRefs(state, output.messages)
+ const assigned = assignMessageRefs(state, output.messages)
+ if (assigned > 0) {
+ await saveSessionState(state, logger)
+ }
syncCompressionBlocks(state, logger, output.messages)
syncToolCache(state, config, logger, output.messages)
buildToolIdList(state, output.messages)
diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts
index 16599e45..a55f95ea 100644
--- a/lib/messages/inject/inject.ts
+++ b/lib/messages/inject/inject.ts
@@ -53,6 +53,24 @@ export const injectCompressNudges = (
state.nudges.contextLimitAnchors.clear()
state.nudges.turnNudgeAnchors.clear()
state.nudges.iterationNudgeAnchors.clear()
+
+ const continuationReminder =
+ "\nCompression complete. Resume your previous task from where you left off."
+ if (lastMessage) {
+ const targetMessage = lastMessage.message
+ let injected = false
+ for (const part of targetMessage.parts) {
+ if (part.type === "text" && typeof part.text === "string") {
+ part.text += continuationReminder
+ injected = true
+ break
+ }
+ }
+ if (!injected) {
+ targetMessage.parts.push(createSyntheticTextPart(targetMessage, continuationReminder))
+ }
+ }
+
void saveSessionState(state, logger)
return
}
diff --git a/lib/prompts/compress-message.ts b/lib/prompts/compress-message.ts
index d0964d36..cd89f591 100644
--- a/lib/prompts/compress-message.ts
+++ b/lib/prompts/compress-message.ts
@@ -1,43 +1,43 @@
-export const COMPRESS_MESSAGE = `Collapse selected individual messages in the conversation into detailed summaries.
+export const COMPRESS_MESSAGE = `将对话中选定的单条消息折叠为详细摘要。
-THE SUMMARY
-Your summary must be EXHAUSTIVE. Capture file paths, function signatures, decisions made, constraints discovered, key findings, tool outcomes, and user intent details that matter... EVERYTHING that preserves the value of the selected message after the raw message is removed.
+摘要要求
+摘要必须详尽。捕获文件路径、函数签名、所做决策、发现的约束、关键发现、工具结果,以及重要的用户意图细节……所有在原始消息被移除后保留其价值的内容。
-USER INTENT FIDELITY
-When a selected message contains user intent, preserve that intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes.
-Directly quote short user instructions when that best preserves exact meaning.
+用户意图保真
+当选定消息包含用户意图时,格外小心地保留该意图。不要改变范围、约束、优先级、验收标准或请求的结果。
+当直接引用简短用户指令最能保留精确含义时,直接引用。
-Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool output, and repetition. What remains should be pure signal - golden nuggets of detail that preserve full understanding with zero ambiguity.
-If a message contains no significant technical decisions, code changes, or user requirements, produce a minimal one-line summary rather than a detailed one.
+同时保持精简。去除噪音:无果的失败尝试、冗长的工具输出和重复。留下的应该是纯信号——保留完整理解、零歧义的精华细节。
+如果消息不包含重要的技术决策、代码变更或用户需求,生成最小化的一行摘要而非详细摘要。
-MESSAGE IDS
-You specify individual raw messages by ID using the injected IDs visible in the conversation:
+消息 ID
+使用对话中可见的注入 ID 按 ID 指定单条原始消息:
-- \`mNNNN\` IDs identify raw messages
+- \`mNNNN\` ID 标识原始消息
-Each message has an ID inside XML metadata tags like \`m0007\`.
-The same ID tag appears in every tool output of the message it belongs to — each unique ID identifies one complete message.
-Treat these tags as message metadata only, not as content to summarize. Use only the inner \`mNNNN\` value as the \`messageId\`.
-The \`priority\` attribute indicates relative context cost. You MUST compress high-priority messages when their full text is no longer necessary for the active task.
-If prior compress-tool results are present, always compress and summarize them minimally only as part of a broader compression pass. Do not invoke the compress tool solely to re-compress an earlier compression result.
-Messages marked as \`BLOCKED\` cannot be compressed.
+每条消息在 XML 元数据标签内有 ID,如 \`\`。
+相同的 ID 标签出现在该消息所属的每个工具输出中——每个唯一 ID 标识一条完整消息。
+将这些标签仅视为消息元数据,而非要摘要的内容。只使用内部的 \`mNNNN\` 值作为 \`messageId\`。
+\`priority\` 属性表示相对上下文成本。当高优先级消息的完整文本对当前任务不再必要时,你必须压缩它们。
+如果存在之前的压缩工具结果,始终在更广泛的压缩过程中将它们最小化地压缩和摘要。不要仅为了重新压缩之前的压缩结果而调用压缩工具。
+标记为 \`\` 的消息不能被压缩。
-Rules:
+规则:
-- Pick each \`messageId\` directly from injected IDs visible in context.
-- Only use raw message IDs of the form \`mNNNN\`.
-- Ignore XML attributes such as \`priority\` when copying the ID; use only the inner \`mNNNN\` value.
-- Do not invent IDs. Use only IDs that are present in context.
+- 直接从上下文中可见的注入 ID 选取每个 \`messageId\`。
+- 只使用 \`mNNNN\` 形式的原始消息 ID。
+- 复制 ID 时忽略 \`priority\` 等 XML 属性;只使用内部的 \`mNNNN\` 值。
+- 不要发明 ID。只使用上下文中存在的 ID。
-BATCHING
-Select MANY messages in a single tool call when they are safe to compress.
-Each entry should summarize exactly one message, and the tool can receive as many entries as needed in one batch.
+批量处理
+当多条消息可以安全压缩时,在单次工具调用中选择多条消息。
+每个条目应恰好摘要一条消息,工具可以在一次批量中接收任意数量的条目。
-GENERAL CLEANUP
-Use the topic "general cleanup" for broad cleanup passes.
-During general cleanup, compress all medium and high-priority messages that are not relevant to the active task.
-Optimize for reducing context footprint, not for grouping messages by topic.
-Do not compress away still-active instructions, unresolved questions, or constraints that are likely to matter soon.
-Prioritize the earliest messages in the context as they will be the least relevant to the active task.
-General cleanup should be done periodically between other normal compression tool passes, not as the primary form of compression.
+通用清理
+使用主题"通用清理"进行广泛的清理。
+在通用清理期间,压缩所有与当前任务无关的中高优先级消息。
+优化减少上下文占用,而非按主题分组消息。
+不要压缩掉仍然活跃的指令、未解决的问题或可能很快重要的约束。
+优先处理上下文中最早的消息,因为它们与当前任务的相关性最低。
+通用清理应定期在其他正常压缩工具调用之间进行,而非作为压缩的主要形式。
`
diff --git a/lib/prompts/compress-range.ts b/lib/prompts/compress-range.ts
index 6e4c983a..8e9b6a44 100644
--- a/lib/prompts/compress-range.ts
+++ b/lib/prompts/compress-range.ts
@@ -1,60 +1,73 @@
-export const COMPRESS_RANGE = `Collapse a range in the conversation into a detailed summary.
+export const COMPRESS_RANGE = `将对话中的一个范围折叠为详细摘要。
-THE SUMMARY
-Your summary must be EXHAUSTIVE. Capture file paths, function signatures, decisions made, constraints discovered, key findings... EVERYTHING that maintains context integrity. This is not a brief note - it is an authoritative record so faithful that the original conversation adds no value.
+输出格式
+每个摘要必须使用 Markdown 标题结构:
-USER INTENT FIDELITY
-When the compressed range includes user messages, preserve the user's intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes.
-Directly quote user messages when they are short enough to include safely. Direct quotes are preferred when they best preserve exact meaning.
+\`\`\`
+## 分析
+[分析:探索了什么、遇到了什么问题、尝试了哪些方法]
-Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool outputs, back-and-forth exploration. What remains should be pure signal - golden nuggets of detail that preserve full understanding with zero ambiguity.
+## 总结
+[总结:最终结论、关键决策、重要文件路径、函数签名、代码变更]
+\`\`\`
-COMPRESSED BLOCK PLACEHOLDERS
-When the selected range includes previously compressed blocks, use this exact placeholder format when referencing one:
+禁止使用 \`\` 或 \`\` 等 XML 标签。只使用 Markdown \`## 分析\` 和 \`## 总结\` 标题。
+
+摘要要求
+摘要必须详尽。捕获文件路径、函数签名、所做决策、发现的约束、关键发现……所有维持上下文完整性的内容。这不是简短笔记——而是一份权威记录,忠实到原始对话不再有任何价值。
+
+用户意图保真
+当压缩范围包含用户消息时,格外小心地保留用户意图。不要改变范围、约束、优先级、验收标准或请求的结果。
+当用户消息足够短时直接引用。直接引用在最能保留精确含义时优先使用。
+
+同时保持精简。去除噪音:无果的失败尝试、冗长的工具输出、反复探索。留下的应该是纯信号——保留完整理解、零歧义的精华细节。
+
+压缩块占位符
+当选定范围包含之前压缩的块时,引用时使用以下精确占位符格式:
- \`(bN)\`
-Compressed block sections in context are clearly marked with a header:
+上下文中的压缩块部分用标题明确标记:
-- \`[Compressed conversation section]\`
+- \`[已压缩的对话部分]\`
-Compressed block IDs always use the \`bN\` form (never \`mNNNN\`) and are represented in the same XML metadata tag format.
+压缩块 ID 始终使用 \`bN\` 形式(不是 \`mNNNN\`),并以相同的 XML 元数据标签格式表示。
-Rules:
+规则:
-- Include every required block placeholder exactly once.
-- Do not invent placeholders for blocks outside the selected range.
-- Treat \`(bN)\` placeholders as RESERVED TOKENS. Do not emit \`(bN)\` text anywhere except intentional placeholders.
-- If you need to mention a block in prose, use plain text like \`compressed bN\` (not as a placeholder).
-- Preflight check before finalizing: the set of \`(bN)\` placeholders in your summary must exactly match the required set, with no duplicates.
+- 每个必需的块占位符恰好包含一次。
+- 不要为选定范围之外的块发明占位符。
+- 将 \`(bN)\` 占位符视为保留标记。除了有意占位符外,不要在任何地方输出 \`(bN)\` 文本。
+- 如果需要在正文中提及一个块,使用纯文本如 \`压缩块 bN\`(不作为占位符)。
+- 最终确定前的预检:摘要中的 \`(bN)\` 占位符集合必须与必需集合完全匹配,无重复。
-These placeholders are semantic references. They will be replaced with the full stored compressed block content when the tool processes your output.
+这些占位符是语义引用。当工具处理你的输出时,它们将被替换为完整的存储压缩块内容。
-FLOW PRESERVATION WITH PLACEHOLDERS
-When you use compressed block placeholders, write the surrounding summary text so it still reads correctly AFTER placeholder expansion.
+占位符的流畅性
+使用压缩块占位符时,编写周围的摘要文本,使其在占位符展开后仍然正确可读。
-- Treat each placeholder as a stand-in for a full conversation segment, not as a short label.
-- Ensure transitions before and after each placeholder preserve chronology and causality.
-- Do not write text that depends on the placeholder staying literal (for example, "as noted in \`(b2)\`").
-- Your final meaning must be coherent once each placeholder is replaced with its full compressed block content.
+- 将每个占位符视为完整对话段的替代,而不是短标签。
+- 确保每个占位符前后的过渡保留时间顺序和因果关系。
+- 不要编写依赖于占位符保持字面意义的文本(例如"如 \`(b2)\` 中所述")。
+- 当每个占位符被替换为其完整压缩块内容后,最终含义必须连贯。
-BOUNDARY IDS
-You specify boundaries by ID using the injected IDs visible in the conversation:
+边界 ID
+使用对话中可见的注入 ID 按 ID 指定边界:
-- \`mNNNN\` IDs identify raw messages
-- \`bN\` IDs identify previously compressed blocks
+- \`mNNNN\` ID 标识原始消息
+- \`bN\` ID 标识之前压缩的块
-Each message has an ID inside XML metadata tags like \`...\`.
-The same ID tag appears in every tool output of the message it belongs to — each unique ID identifies one complete message.
-Treat these tags as boundary metadata only, not as tool result content.
+每条消息在 XML 元数据标签内有 ID,如 \`\`。
+相同的 ID 标签出现在该消息所属的每个工具输出中——每个唯一 ID 标识一条完整消息。
+将这些标签仅视为边界元数据,而非工具结果内容。
-Rules:
+规则:
-- Pick \`startId\` and \`endId\` directly from injected IDs in context.
-- IDs must exist in the current visible context.
-- \`startId\` must appear before \`endId\`.
-- Do not invent IDs. Use only IDs that are present in context.
+- 直接从上下文中的注入 ID 选取 \`startId\` 和 \`endId\`。
+- ID 必须存在于当前可见上下文中。
+- \`startId\` 必须出现在 \`endId\` 之前。
+- 不要发明 ID。只使用上下文中存在的 ID。
-BATCHING
-When multiple independent ranges are ready and their boundaries do not overlap, include all of them as separate entries in the \`content\` array of a single tool call. Each entry should have its own \`startId\`, \`endId\`, and \`summary\`.
+批量处理
+当多个独立范围已准备好且边界不重叠时,将它们全部作为单独条目包含在单次工具调用的 \`content\` 数组中。每个条目应有自己的 \`startId\`、\`endId\` 和 \`summary\`。
`
diff --git a/lib/prompts/extensions/nudge.ts b/lib/prompts/extensions/nudge.ts
index 7137eb01..3663454c 100644
--- a/lib/prompts/extensions/nudge.ts
+++ b/lib/prompts/extensions/nudge.ts
@@ -6,22 +6,24 @@ export function buildCompressedBlockGuidance(state: SessionState): string {
.sort((a, b) => a - b)
.map((id) => `b${id}`)
const blockCount = refs.length
- const blockList = blockCount > 0 ? refs.join(", ") : "none"
+ const blockList = blockCount > 0 ? refs.join(", ") : "无"
return [
- "Compressed block context:",
- `- Active compressed blocks in this session: ${blockCount} (${blockList})`,
- "- If your selected compression range includes any listed block, include each required placeholder exactly once in the summary using `(bN)`.",
+ "压缩块上下文:",
+ `- 此会话中的活跃压缩块:${blockCount} 个(${blockList})`,
+ "- 如果你选择的压缩范围包含任何列出的块,在摘要中使用 `(bN)` 恰好包含每个必需的占位符一次。",
].join("\n")
}
export function renderMessagePriorityGuidance(priorityLabel: string, refs: string[]): string {
- const refList = refs.length > 0 ? refs.join(", ") : "none"
+ const refList = refs.length > 0 ? refs.join(", ") : "无"
+
+ const priorityLabelZh = priorityLabel === "high" ? "高" : priorityLabel === "medium" ? "中" : "低"
return [
- "Message priority context:",
- "- Higher-priority older messages consume more context and should be compressed right away if it is safe to do so.",
- `- ${priorityLabel}-priority message IDs before this point: ${refList}`,
+ "消息优先级上下文:",
+ "- 高优先级的旧消息消耗更多上下文,如果安全的话应立即压缩。",
+ `- 此点之前的${priorityLabelZh}优先级消息 ID:${refList}`,
].join("\n")
}
@@ -30,7 +32,7 @@ export function appendGuidanceToDcpTag(nudgeText: string, guidance: string): str
return nudgeText
}
- const closeTag = ""
+ const closeTag = ""
const closeTagIndex = nudgeText.lastIndexOf(closeTag)
if (closeTagIndex === -1) {
diff --git a/lib/prompts/extensions/system.ts b/lib/prompts/extensions/system.ts
index f5f038bd..6ad27cd2 100644
--- a/lib/prompts/extensions/system.ts
+++ b/lib/prompts/extensions/system.ts
@@ -1,21 +1,9 @@
-export const MANUAL_MODE_SYSTEM_EXTENSION = `
-Manual mode is enabled. Do NOT use compress unless the user has explicitly triggered it through a manual marker.
-
-Only use the compress tool after seeing \`\` in the current user instruction context.
-
-Issue exactly ONE compress tool per manual trigger. Do NOT launch multiple compress tools in parallel. Each trigger grants a single compression; after it completes, wait for the next trigger.
-
-After completing a manually triggered context-management action, STOP IMMEDIATELY. Do NOT continue with any task execution. End your response right after the tool use completes and wait for the next user input.
-
+export const MANUAL_MODE_SYSTEM_EXTENSION = `
+手动模式已启用。压缩操作需要用户明确触发。
`
-export const SUBAGENT_SYSTEM_EXTENSION = `
-You are operating in a subagent environment.
-
-The initial subagent instruction is imperative and must be followed exactly.
-It is the only user message intentionally not assigned a message ID, and therefore is not eligible for compression.
-All subsequent messages in the session will have IDs.
-
+export const SUBAGENT_SYSTEM_EXTENSION = `
+子代理模式:压缩功能已禁用。
`
export function buildProtectedToolsExtension(protectedTools: string[]): string {
@@ -24,9 +12,5 @@ export function buildProtectedToolsExtension(protectedTools: string[]): string {
}
const toolList = protectedTools.map((t) => `\`${t}\``).join(", ")
- return `
-The following tools are environment-managed: ${toolList}.
-Their outputs are automatically preserved during compression.
-Do not include their content in compress tool summaries — the environment retains it independently.
-`
+ return `以下工具的输出受保护,压缩时不要包含其完整内容:${toolList}`
}
diff --git a/lib/prompts/extensions/tool.ts b/lib/prompts/extensions/tool.ts
index ff852ac7..89b97e9c 100644
--- a/lib/prompts/extensions/tool.ts
+++ b/lib/prompts/extensions/tool.ts
@@ -1,34 +1,34 @@
-// These format schemas are kept separate from the editable compress prompts
-// so they cannot be modified via custom prompt overrides. The schemas must
-// match the tool's input validation and are not safe to change independently.
+// 这些格式模式与可编辑的压缩提示分开保存
+// 因此不能通过自定义提示覆盖来修改。这些模式必须
+// 与工具的输入验证匹配,且不能独立更改。
export const RANGE_FORMAT_EXTENSION = `
-THE FORMAT OF COMPRESS
+压缩格式
\`\`\`
{
- topic: string, // Short label (3-5 words) - e.g., "Auth System Exploration"
- content: [ // One or more ranges to compress
+ topic: string, // 短标签(3-5 个词)- 例如,"认证系统探索"
+ content: [ // 一个或多个要压缩的范围
{
- startId: string, // Boundary ID at range start: mNNNN or bN
- endId: string, // Boundary ID at range end: mNNNN or bN
- summary: string // Complete technical summary replacing all content in range
+ startId: string, // 范围开始的边界 ID:mNNNN 或 bN
+ endId: string, // 范围结束的边界 ID:mNNNN 或 bN
+ summary: string // 替换范围内所有内容的完整技术摘要
}
]
}
\`\`\``
export const MESSAGE_FORMAT_EXTENSION = `
-THE FORMAT OF COMPRESS
+压缩格式
\`\`\`
{
- topic: string, // Short label (3-5 words) for the overall batch
- content: [ // One or more messages to compress independently
+ topic: string, // 整个批次的短标签(3-5 个词)
+ content: [ // 一个或多个要独立压缩的消息
{
- messageId: string, // Raw message ID only: mNNNN (ignore metadata attributes like priority)
- topic: string, // Short label (3-5 words) for this one message summary
- summary: string // Complete technical summary replacing that one message
+ messageId: string, // 仅原始消息 ID:mNNNN(忽略 priority 等元数据属性)
+ topic: string, // 此单条消息摘要的短标签(3-5 个词)
+ summary: string // 替换该条消息的完整技术摘要
}
]
}
diff --git a/lib/prompts/iteration-nudge.ts b/lib/prompts/iteration-nudge.ts
index f8e4fa9b..5ad74a1a 100644
--- a/lib/prompts/iteration-nudge.ts
+++ b/lib/prompts/iteration-nudge.ts
@@ -1,6 +1,3 @@
-export const ITERATION_NUDGE = `
-You've been iterating for a while after the last user message.
-
-If there is a closed portion that is unlikely to be referenced immediately (for example, finished research before implementation), use the compress tool on it now.
-
+export const ITERATION_NUDGE = `
+已进行多次迭代但未收到新的用户输入。考虑压缩早期步骤以保持上下文清晰。
`
diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts
index da3cd41e..44d44be6 100644
--- a/lib/prompts/system.ts
+++ b/lib/prompts/system.ts
@@ -1,33 +1,40 @@
export const SYSTEM = `
-You operate in a context-constrained environment. Manage context continuously to avoid buildup and preserve retrieval quality. Efficient context management is paramount for your agentic performance.
+你在一个上下文受限的环境中运行。持续管理上下文以避免堆积并保持检索质量。高效的上下文管理对你的代理性能至关重要。
-The ONLY tool you have for context management is \`compress\`. It replaces older conversation content with technical summaries you produce.
+你用于上下文管理的唯一工具是 \`compress\`。它用你生成的技术摘要替换较旧的对话内容。
-\`\` and \`\` tags are environment-injected metadata. Do not output them.
+关键约束
+- 上下文管理仅通过调用 \`compress\` 工具完成。绝不以纯文本形式生成摘要。
+- **严格禁止**在文本响应中输出 \`\` 或 \`\` XML 标签。这些标签会导致系统错误。
+- 如果你认为上下文需要压缩,调用 \`compress\` 工具。不要内联编写摘要。
+- 所有输出必须使用**中文**。
+- 如果需要在文本中组织信息,使用 Markdown 格式(如 \`## 分析\`、\`## 总结\`),绝不使用 XML 标签。
-THE PHILOSOPHY OF COMPRESS
-\`compress\` transforms conversation content into dense, high-fidelity summaries. This is not cleanup - it is crystallization. Your summary becomes the authoritative record of what transpired.
+\`\` 和 \`\` 标签是环境注入的元数据。不要输出它们。
-Think of compression as phase transitions: raw exploration becomes refined understanding. The original context served its purpose; your summary now carries that understanding forward.
+压缩的哲学
+\`compress\` 将对话内容转换为密集、高保真的摘要。这不是清理——而是结晶。你的摘要成为所发生事情的权威记录。
-COMPRESS WHEN
+将压缩视为相变:原始探索变为精炼理解。原始上下文已完成其使命;你的摘要现在承载该理解向前。
-A section is genuinely closed and the raw conversation has served its purpose:
+何时压缩
-- Research concluded and findings are clear
-- Implementation finished and verified
-- Exploration exhausted and patterns understood
-- Dead-end noise can be discarded without waiting for a whole chapter to close
+当一个部分真正关闭且原始对话已完成其使命时:
-DO NOT COMPRESS IF
+- 研究已完成且发现已明确
+- 实现已完成并验证
+- 探索已穷尽且模式已理解
+- 死胡同噪音可以在不等待整个章节关闭的情况下被丢弃
-- Raw context is still relevant and needed for edits or precise references
-- The target content is still actively in progress
-- You may need exact code, error messages, or file contents in the immediate next steps
+不要压缩的情况
-Before compressing, ask: _"Is this section closed enough to become summary-only right now?"_
+- 原始上下文仍然相关且需要用于编辑或精确引用
+- 目标内容仍在积极进行中
+- 你可能在接下来的步骤中需要确切的代码、错误消息或文件内容
-Evaluate conversation signal-to-noise REGULARLY. Use \`compress\` deliberately with quality-first summaries. Prioritize stale content intelligently to maintain a high-signal context window that supports your agency.
+压缩前问自己:_"这个部分现在是否足够关闭以成为仅摘要?"_
-It is of your responsibility to keep a sharp, high-quality context window for optimal performance.
+定期评估对话的信噪比。有意识地使用 \`compress\` 并提供高质量摘要。智能地优先处理过时内容,以维护支持你代理能力的高信号上下文窗口。
+
+保持锐利、高质量的上下文窗口以获得最佳性能是你的责任。
`
diff --git a/lib/prompts/turn-nudge.ts b/lib/prompts/turn-nudge.ts
index 9f64f108..80ecfe91 100644
--- a/lib/prompts/turn-nudge.ts
+++ b/lib/prompts/turn-nudge.ts
@@ -1,10 +1,3 @@
-export const TURN_NUDGE = `
-Evaluate the conversation for compressible ranges.
-
-If any messages are cleanly closed and unlikely to be needed again, use the compress tool on them.
-If direction has shifted, compress earlier ranges that are now less relevant.
-
-The goal is to filter noise and distill key information so context accumulation stays under control.
-Keep active context uncompressed.
-
+export const TURN_NUDGE = `
+上下文使用量处于中等水平。考虑压缩已完成的对话轮次以释放空间。
`
diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts
index 87b774f9..2f41b1c1 100644
--- a/lib/state/persistence.ts
+++ b/lib/state/persistence.ts
@@ -8,7 +8,7 @@ import * as fs from "fs/promises"
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
-import type { CompressionBlock, PrunedMessageEntry, SessionState, SessionStats } from "./types"
+import type { CompressionBlock, MessageIdState, PrunedMessageEntry, SessionState, SessionStats } from "./types"
import type { Logger } from "../logger"
import { serializePruneMessagesState } from "./utils"
@@ -33,11 +33,79 @@ export interface PersistedNudges {
iterationNudgeAnchors?: string[]
}
+export interface PersistedMessageIds {
+ byRawId: Record
+ nextRef: number
+}
+
+const MESSAGE_REF_REGEX = /^m\d{4}$/
+const MESSAGE_REF_MIN_INDEX = 1
+const MESSAGE_REF_MAX_INDEX = 9999
+const MESSAGE_REF_LIMIT = MESSAGE_REF_MAX_INDEX + 1
+
+function parsePersistedMessageRef(ref: string): number | null {
+ if (!MESSAGE_REF_REGEX.test(ref)) {
+ return null
+ }
+ const index = Number.parseInt(ref.slice(1), 10)
+ if (index < MESSAGE_REF_MIN_INDEX || index > MESSAGE_REF_MAX_INDEX) {
+ return null
+ }
+ return index
+}
+
+export function loadMessageIdState(persisted?: PersistedMessageIds): MessageIdState {
+ const state: MessageIdState = {
+ byRawId: new Map(),
+ byRef: new Map(),
+ nextRef: 1,
+ }
+
+ if (!persisted || typeof persisted !== "object") {
+ return state
+ }
+
+ let maxValidRefIndex = 0
+ const byRawId = persisted.byRawId
+ if (byRawId && typeof byRawId === "object") {
+ for (const [rawId, ref] of Object.entries(byRawId)) {
+ if (typeof rawId !== "string" || rawId.length === 0 || typeof ref !== "string") {
+ continue
+ }
+
+ const refIndex = parsePersistedMessageRef(ref)
+ if (refIndex === null || state.byRef.has(ref)) {
+ continue
+ }
+
+ state.byRawId.set(rawId, ref)
+ state.byRef.set(ref, rawId)
+ maxValidRefIndex = Math.max(maxValidRefIndex, refIndex)
+ }
+ }
+
+ const persistedNextRef = persisted.nextRef
+ const hasValidPersistedNextRef =
+ typeof persistedNextRef === "number" &&
+ Number.isInteger(persistedNextRef) &&
+ persistedNextRef >= MESSAGE_REF_MIN_INDEX &&
+ persistedNextRef <= MESSAGE_REF_LIMIT
+ state.nextRef = Math.max(
+ hasValidPersistedNextRef ? persistedNextRef : MESSAGE_REF_MIN_INDEX,
+ maxValidRefIndex + 1,
+ )
+
+ return state
+}
+
export interface PersistedSessionState {
+ schemaVersion?: number
sessionName?: string
prune: PersistedPrune
nudges: PersistedNudges
stats: SessionStats
+ lastCompaction?: number
+ messageIds?: PersistedMessageIds
lastUpdated: string
}
@@ -59,6 +127,28 @@ function getSessionFilePath(sessionId: string): string {
return join(STORAGE_DIR, `${sessionId}.json`)
}
+async function readExistingLastCompaction(sessionId: string): Promise {
+ const filePath = getSessionFilePath(sessionId)
+ if (!existsSync(filePath)) {
+ return 0
+ }
+
+ try {
+ const content = await fs.readFile(filePath, "utf-8")
+ const parsed: unknown = JSON.parse(content)
+ if (!parsed || typeof parsed !== "object" || !("lastCompaction" in parsed)) {
+ return 0
+ }
+
+ const lastCompaction = (parsed as { lastCompaction?: unknown }).lastCompaction
+ return typeof lastCompaction === "number" && Number.isFinite(lastCompaction)
+ ? lastCompaction
+ : 0
+ } catch {
+ return 0
+ }
+}
+
async function writePersistedSessionState(
sessionId: string,
state: PersistedSessionState,
@@ -86,7 +176,10 @@ export async function saveSessionState(
return
}
+ const existingLastCompaction = await readExistingLastCompaction(sessionState.sessionId)
+
const state: PersistedSessionState = {
+ schemaVersion: 1,
sessionName: sessionName,
prune: {
tools: Object.fromEntries(sessionState.prune.tools),
@@ -98,6 +191,11 @@ export async function saveSessionState(
iterationNudgeAnchors: Array.from(sessionState.nudges.iterationNudgeAnchors),
},
stats: sessionState.stats,
+ lastCompaction: Math.max(sessionState.lastCompaction, existingLastCompaction),
+ messageIds: {
+ byRawId: Object.fromEntries(sessionState.messageIds.byRawId),
+ nextRef: sessionState.messageIds.nextRef,
+ },
lastUpdated: new Date().toISOString(),
}
diff --git a/lib/state/state.ts b/lib/state/state.ts
index 6a2e3301..0820f147 100644
--- a/lib/state/state.ts
+++ b/lib/state/state.ts
@@ -1,12 +1,11 @@
import type { SessionState, ToolParameterEntry, WithParts } from "./types"
import type { Logger } from "../logger"
import { applyPendingCompressionDurations } from "../compress/timing"
-import { loadSessionState, saveSessionState } from "./persistence"
+import { loadMessageIdState, loadSessionState, saveSessionState } from "./persistence"
import {
isSubAgentSession,
findLastCompactionTimestamp,
countTurns,
- resetOnCompaction,
createPruneMessagesState,
loadPruneMessagesState,
loadPruneMap,
@@ -44,16 +43,16 @@ export const checkSession = async (
}
}
+ // opencode /compact preserves all msg_* rows in DB, so DCP state remains valid
const lastCompactionTimestamp = findLastCompactionTimestamp(messages)
if (lastCompactionTimestamp > state.lastCompaction) {
state.lastCompaction = lastCompactionTimestamp
- resetOnCompaction(state)
- logger.info("Detected compaction - reset stale state", {
+ logger.info("Detected compaction timestamp advance — DCP state preserved", {
timestamp: lastCompactionTimestamp,
})
saveSessionState(state, logger).catch((error) => {
- logger.warn("Failed to persist state reset after compaction", {
+ logger.warn("Failed to persist compaction timestamp", {
error: error instanceof Error ? error.message : String(error),
})
})
@@ -181,6 +180,14 @@ export async function ensureSessionInitialized(
totalPruneTokens: persisted.stats?.totalPruneTokens || 0,
}
+ state.lastCompaction = Math.max(
+ state.lastCompaction,
+ typeof persisted.lastCompaction === "number" && Number.isFinite(persisted.lastCompaction)
+ ? persisted.lastCompaction
+ : 0,
+ )
+ state.messageIds = loadMessageIdState(persisted.messageIds)
+
const applied = applyPendingCompressionDurations(state)
if (applied > 0) {
await saveSessionState(state, logger)
diff --git a/lib/state/utils.ts b/lib/state/utils.ts
index f0caf267..d7c9f0f8 100644
--- a/lib/state/utils.ts
+++ b/lib/state/utils.ts
@@ -328,18 +328,6 @@ export function getActiveSummaryTokenUsage(state: SessionState): number {
return total
}
-export function resetOnCompaction(state: SessionState): void {
- state.toolParameters.clear()
- state.prune.tools = new Map()
- state.prune.messages = createPruneMessagesState()
- state.messageIds = {
- byRawId: new Map(),
- byRef: new Map(),
- nextRef: 1,
- }
- state.nudges = {
- contextLimitAnchors: new Set(),
- turnNudgeAnchors: new Set(),
- iterationNudgeAnchors: new Set(),
- }
+export function resetOnCompaction(_state: SessionState): void {
+ // opencode /compact preserves all msg_* rows, so DCP state remains valid
}
diff --git a/tests/message-ids.test.ts b/tests/message-ids.test.ts
index f128b766..d1ad94ed 100644
--- a/tests/message-ids.test.ts
+++ b/tests/message-ids.test.ts
@@ -58,32 +58,32 @@ function buildCompactedMessages(sessionID: string): WithParts[] {
]
}
-test("checkSession resets message id aliases after native compaction", async () => {
+test("checkSession preserves message id aliases after native compaction", async () => {
const sessionID = `ses_message_ids_after_compaction_${Date.now()}`
const messages = buildCompactedMessages(sessionID)
const state = createSessionState()
const logger = new Logger(false)
state.sessionId = sessionID
- state.messageIds.byRawId.set("old-message-9998", "m9998")
- state.messageIds.byRawId.set("old-message-9999", "m9999")
- state.messageIds.byRef.set("m9998", "old-message-9998")
- state.messageIds.byRef.set("m9999", "old-message-9999")
- state.messageIds.nextRef = 9999
+ state.messageIds.byRawId.set("old-message-1", "m0001")
+ state.messageIds.byRawId.set("old-message-2", "m0002")
+ state.messageIds.byRef.set("m0001", "old-message-1")
+ state.messageIds.byRef.set("m0002", "old-message-2")
+ state.messageIds.nextRef = 3
await checkSession({} as any, state, logger, messages, false)
- assert.equal(state.lastCompaction, 2)
- assert.equal(state.messageIds.byRawId.size, 0)
- assert.equal(state.messageIds.byRef.size, 0)
- assert.equal(state.messageIds.nextRef, 1)
+ // Message IDs are preserved after compaction (PR #530 behavior)
+ assert.equal(state.messageIds.byRawId.size, 2)
+ assert.equal(state.messageIds.byRef.size, 2)
+ assert.equal(state.messageIds.nextRef, 3)
const assigned = assignMessageRefs(state, messages)
assert.equal(assigned, 2)
- assert.equal(state.messageIds.byRawId.get("msg-assistant-summary"), "m0001")
- assert.equal(state.messageIds.byRawId.get("msg-user-follow-up"), "m0002")
- assert.equal(state.messageIds.byRef.get("m0001"), "msg-assistant-summary")
- assert.equal(state.messageIds.byRef.get("m0002"), "msg-user-follow-up")
- assert.equal(state.messageIds.nextRef, 3)
+ assert.equal(state.messageIds.byRawId.get("msg-assistant-summary"), "m0003")
+ assert.equal(state.messageIds.byRawId.get("msg-user-follow-up"), "m0004")
+ assert.equal(state.messageIds.byRef.get("m0003"), "msg-assistant-summary")
+ assert.equal(state.messageIds.byRef.get("m0004"), "msg-user-follow-up")
+ assert.equal(state.messageIds.nextRef, 5)
})