feat(compaction): add /anchor compaction facts (#930)

Integrates source PR #525 by @shentoumengxin.

Adds `/anchor` for critical user facts that should survive compaction via `.deepseek/anchors.md`. Keeps `/pin` unregistered so the resident-context `/pin` lane remains available, and renders anchors as structured bullets in compaction summaries instead of raw separator text.

Local verification:
- cargo fmt --all -- --check
- cargo test -p deepseek-tui anchor --all-features
- cargo build

CI: all required checks passed on #930.

Co-authored-by: ZZHAsus <3075047037@qq.com>
This commit is contained in:
Hunter Bown
2026-05-06 20:45:22 -05:00
committed by GitHub
parent 29d57c7518
commit ebcffaadf9
4 changed files with 369 additions and 1 deletions
+280
View File
@@ -0,0 +1,280 @@
//! Anchor command: keep critical facts across compaction.
//!
//! Unlike `/note` (active lookup), anchors are passive. They are automatically
//! re-injected into context after every compaction cycle. Use anchors to
//! preserve invariants like "This API's status field is unreliable" or
//! ".ssh/ must never be touched".
use crate::tui::app::App;
use std::fs;
use std::io::Write;
use super::CommandResult;
const USAGE: &str = "/anchor <text> | /anchor list | /anchor remove <n>";
/// Handle the `/anchor` command with subcommands:
/// /anchor <text> - add a new anchor
/// /anchor list - list all anchors
/// /anchor remove <n> - remove anchor by 1-based index
pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult {
let input = match content {
Some(c) => c.trim(),
None => {
return CommandResult::error(format!("Usage: {USAGE}"));
}
};
if input.is_empty() {
return CommandResult::error(format!("Usage: {USAGE}"));
}
// Parse subcommands.
if input.eq_ignore_ascii_case("list") {
return list_anchors(app);
}
if let Some(rest) = input
.strip_prefix("remove ")
.or_else(|| input.strip_prefix("rm "))
.or_else(|| input.strip_prefix("delete "))
{
return remove_anchor(app, rest.trim());
}
// Default: add a new anchor.
add_anchor(app, input)
}
fn anchors_path(app: &App) -> std::path::PathBuf {
app.workspace.join(".deepseek").join("anchors.md")
}
/// Read and split anchors from the file. Each anchor is separated by "\n---\n".
fn read_anchors(app: &App) -> Vec<String> {
let path = anchors_path(app);
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
content
.split("\n---\n")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Write anchors back to the file, joined by "\n---\n".
fn write_anchors(app: &App, anchors: &[String]) -> Result<(), String> {
let path = anchors_path(app);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create anchors directory: {e}"))?;
}
let content = anchors.join("\n---\n");
fs::write(&path, content).map_err(|e| format!("Failed to write anchors file: {e}"))
}
fn add_anchor(app: &mut App, text: &str) -> CommandResult {
let path = anchors_path(app);
// Ensure parent directory exists.
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
return CommandResult::error(format!("Failed to create anchors directory: {e}"));
}
// Append to anchors file.
let mut file = match fs::OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => f,
Err(e) => {
return CommandResult::error(format!("Failed to open anchors file: {e}"));
}
};
// Write separator and anchor content.
if let Err(e) = writeln!(file, "\n---\n{}", text) {
return CommandResult::error(format!("Failed to write anchor: {e}"));
}
CommandResult::message(format!(
"Anchor pinned. It will be auto-injected into context after each compaction.\n\
Stored in: {}",
path.display()
))
}
fn list_anchors(app: &App) -> CommandResult {
let anchors = read_anchors(app);
if anchors.is_empty() {
return CommandResult::message(
"No anchors set. Use /anchor <text> to pin a fact that survives compaction.",
);
}
let mut output = format!("Pinned anchors ({} total):\n", anchors.len());
for (i, anchor) in anchors.iter().enumerate() {
output.push_str(&format!("\n {}. {}", i + 1, anchor));
}
output.push_str("\n\nUse /anchor remove <n> to remove an anchor.");
CommandResult::message(output)
}
fn remove_anchor(app: &mut App, index_str: &str) -> CommandResult {
let index: usize = match index_str.parse() {
Ok(n) if n >= 1 => n,
_ => {
return CommandResult::error(
"Invalid index. Use /anchor list to see anchor numbers, then /anchor remove <n>.",
);
}
};
let mut anchors = read_anchors(app);
if index > anchors.len() {
return CommandResult::error(format!(
"Anchor #{index} does not exist. You have {} anchor(s). Use /anchor list to see them.",
anchors.len()
));
}
let removed = anchors.remove(index - 1);
if let Err(e) = write_anchors(app, &anchors) {
return CommandResult::error(e);
}
CommandResult::message(format!("Removed anchor #{index}: {removed}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
config_path: None,
config_profile: None,
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
use_bracketed_paste: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
initial_input: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_anchor_without_content_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = anchor(&mut app, None);
assert!(result.is_error);
assert!(result.message.unwrap().contains("Usage:"));
}
#[test]
fn test_anchor_with_empty_content_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = anchor(&mut app, Some(" "));
assert!(result.is_error);
assert!(result.message.unwrap().contains("Usage:"));
}
#[test]
fn test_anchor_add() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = anchor(&mut app, Some("API status field is unreliable"));
assert!(!result.is_error);
assert!(result.message.unwrap().contains("Anchor pinned"));
let path = tmpdir.path().join(".deepseek").join("anchors.md");
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("API status field is unreliable"));
}
#[test]
fn test_anchor_list_empty() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = anchor(&mut app, Some("list"));
assert!(!result.is_error);
assert!(result.message.unwrap().contains("No anchors set"));
}
#[test]
fn test_anchor_list_with_items() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
anchor(&mut app, Some("First anchor"));
anchor(&mut app, Some("Second anchor"));
let result = anchor(&mut app, Some("list"));
let msg = result.message.unwrap();
assert!(msg.contains("2 total"));
assert!(msg.contains("1. First anchor"));
assert!(msg.contains("2. Second anchor"));
}
#[test]
fn test_anchor_remove() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
anchor(&mut app, Some("First anchor"));
anchor(&mut app, Some("Second anchor"));
let result = anchor(&mut app, Some("remove 1"));
assert!(!result.is_error);
assert!(result.message.unwrap().contains("Removed anchor #1"));
let result = anchor(&mut app, Some("list"));
let msg = result.message.unwrap();
assert!(msg.contains("1 total"));
assert!(msg.contains("Second anchor"));
assert!(!msg.contains("First anchor"));
}
#[test]
fn test_anchor_remove_invalid_index() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
anchor(&mut app, Some("Only anchor"));
let result = anchor(&mut app, Some("remove 5"));
assert!(result.is_error);
assert!(result.message.unwrap().contains("does not exist"));
}
#[test]
fn test_anchor_remove_non_numeric() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = anchor(&mut app, Some("remove abc"));
assert!(result.is_error);
assert!(result.message.unwrap().contains("Invalid index"));
}
}
+8
View File
@@ -3,6 +3,7 @@
//! This module provides a modular command system inspired by Codex-rs.
//! Commands are organized by category and dispatched through a central registry.
mod anchor;
mod attachment;
mod config;
mod core;
@@ -134,6 +135,12 @@ impl CommandInfo {
/// All registered commands
pub const COMMANDS: &[CommandInfo] = &[
// Core commands
CommandInfo {
name: "anchor",
aliases: &[],
usage: "/anchor <text> | /anchor list | /anchor remove <n>",
description_id: MessageId::CmdAnchorDescription,
},
CommandInfo {
name: "help",
aliases: &["?"],
@@ -489,6 +496,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
// Match command or alias
match command {
// Core commands
"anchor" => anchor::anchor(app, arg),
"help" | "?" => core::help(app, arg),
"clear" => core::clear(app),
"exit" | "quit" | "q" => core::exit(),
+69 -1
View File
@@ -949,6 +949,44 @@ pub async fn compact_messages_safe(
.unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries")))
}
fn read_workspace_anchors(workspace: Option<&Path>) -> Vec<String> {
let Some(ws) = workspace else {
return Vec::new();
};
let anchors_path = ws.join(".deepseek").join("anchors.md");
let Ok(content) = std::fs::read_to_string(anchors_path) else {
return Vec::new();
};
content
.split("\n---\n")
.map(str::trim)
.filter(|anchor| !anchor.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn anchor_summary_section(workspace: Option<&Path>) -> String {
let anchors = read_workspace_anchors(workspace);
if anchors.is_empty() {
return String::new();
}
let mut section = String::from(
"## Pinned Facts (User Anchors)\n\n\
The following facts were explicitly anchored by the user with `/anchor`. \
Preserve them across compaction cycles.\n\n",
);
for anchor in anchors {
let _ = writeln!(section, "- {anchor}");
}
section.push_str("\n---\n\n");
section
}
pub async fn compact_messages(
client: &DeepSeekClient,
messages: &[Message],
@@ -984,11 +1022,14 @@ pub async fn compact_messages(
// Extract workflow context (files touched, tasks in progress, etc.)
let workflow_context = extract_workflow_context(&to_summarize, workspace);
let anchors_section = anchor_summary_section(workspace);
// Build new message list with enhanced summary as system block
let summary_block = SystemBlock {
block_type: "text".to_string(),
text: format!(
"## 📋 Conversation Summary (Auto-Generated)\n\n\
"{anchors_section}\
## 📋 Conversation Summary (Auto-Generated)\n\n\
{summary}\n\n\
---\n\n\
## 🔍 Workflow Context\n\n\
@@ -1454,6 +1495,33 @@ mod tests {
}
}
#[test]
fn anchor_summary_section_is_empty_without_workspace_or_file() {
assert!(anchor_summary_section(None).is_empty());
let tmpdir = tempfile::TempDir::new().unwrap();
assert!(anchor_summary_section(Some(tmpdir.path())).is_empty());
}
#[test]
fn anchor_summary_section_parses_anchor_file_into_bullets() {
let tmpdir = tempfile::TempDir::new().unwrap();
let deepseek_dir = tmpdir.path().join(".deepseek");
std::fs::create_dir_all(&deepseek_dir).unwrap();
std::fs::write(
deepseek_dir.join("anchors.md"),
"\n---\nDo not touch .ssh\n---\nStatus field is unreliable\n",
)
.unwrap();
let section = anchor_summary_section(Some(tmpdir.path()));
assert!(section.contains("## Pinned Facts (User Anchors)"));
assert!(section.contains("- Do not touch .ssh\n"));
assert!(section.contains("- Status field is unreliable\n"));
assert!(!section.contains("\n---\nDo not touch"));
}
#[test]
fn truncate_chars_respects_unicode_boundaries() {
let text = "abc😀é";
+12
View File
@@ -216,6 +216,7 @@ pub enum MessageId {
HelpFooterClose,
CmdAgentDescription,
CmdAttachDescription,
CmdAnchorDescription,
CmdCacheDescription,
CmdClearDescription,
CmdCompactDescription,
@@ -405,6 +406,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::HelpFooterJump,
MessageId::HelpFooterClose,
MessageId::CmdAgentDescription,
MessageId::CmdAnchorDescription,
MessageId::CmdAttachDescription,
MessageId::CmdCacheDescription,
MessageId::CmdClearDescription,
@@ -714,6 +716,9 @@ fn english(id: MessageId) -> &'static str {
MessageId::HelpFooterJump => " PgUp/PgDn jump ",
MessageId::HelpFooterClose => " Esc close ",
MessageId::CmdAgentDescription => "Switch to agent mode",
MessageId::CmdAnchorDescription => {
"Pin a fact that survives compaction (auto-injected into context)"
}
MessageId::CmdAttachDescription => {
"Attach image/video media; use @path for text files or directories"
}
@@ -994,6 +999,9 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::HelpFooterJump => " PgUp/PgDn ジャンプ ",
MessageId::HelpFooterClose => " Esc 閉じる ",
MessageId::CmdAgentDescription => "Agent モードに切り替え",
MessageId::CmdAnchorDescription => {
"コンパクション後も保持される重要な事実をピン留め(コンテキストに自動注入)"
}
MessageId::CmdAttachDescription => {
"画像・動画メディアを添付(テキストファイルやディレクトリは @path)"
}
@@ -1266,6 +1274,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::HelpFooterJump => " PgUp/PgDn 跳转 ",
MessageId::HelpFooterClose => " Esc 关闭 ",
MessageId::CmdAgentDescription => "切换到 Agent 模式",
MessageId::CmdAnchorDescription => "钉选关键事实,在压缩后自动注入上下文",
MessageId::CmdAttachDescription => "附加图片或视频媒体;文本文件或目录请使用 @path",
MessageId::CmdCacheDescription => "显示最近 N 轮的 DeepSeek 前缀缓存命中/未命中统计",
MessageId::CmdClearDescription => "清除对话历史",
@@ -1508,6 +1517,9 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::HelpFooterJump => " PgUp/PgDn salta ",
MessageId::HelpFooterClose => " Esc fecha ",
MessageId::CmdAgentDescription => "Mudar para o modo agent",
MessageId::CmdAnchorDescription => {
"Fixar um fato que sobrevive à compactação (injetado automaticamente no contexto)"
}
MessageId::CmdAttachDescription => {
"Anexar imagem ou vídeo; use @path para arquivos de texto ou diretórios"
}