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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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😀é";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user