j-cli 12.9.15

A fast CLI tool for alias management, daily reports, and productivity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
use super::super::constants::{
    COMPACT_KEEP_RECENT, COMPACT_KEEP_RECENT_USER_MESSAGES, COMPACT_SKILL_PER_SKILL_TOKEN_BUDGET,
    COMPACT_SKILL_TOKEN_BUDGET, COMPACT_TOKEN_THRESHOLD, COMPACT_TRUNCATE_MAX_CHARS,
    MICRO_COMPACT_BYTES_THRESHOLD, ROLE_ASSISTANT, ROLE_TOOL, ROLE_USER,
};
use super::super::storage::{ChatMessage, ModelProvider, SessionPaths};
use super::super::tools::ask::AskTool;
use super::super::tools::skill::LoadSkillTool;
use super::super::tools::task::TaskTool;
use super::super::tools::todo::{TodoReadTool, TodoWriteTool};
use super::api::create_openai_client;
use crate::command::chat::tools::agent_team::AgentTeamTool;
use crate::command::chat::tools::plan::{EnterPlanModeTool, ExitPlanModeTool};
use crate::command::chat::tools::sub_agent::SubAgentTool;
use crate::util::log::{write_error_log, write_info_log};
use async_openai::types::chat::{
    ChatCompletionRequestMessage, ChatCompletionRequestUserMessageArgs,
    CreateChatCompletionRequestArgs,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};

// ========== InvokedSkills 追踪 ==========

/// 记录一次技能调用的完整信息(用于 auto_compact 后恢复)
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InvokedSkill {
    /// 技能名称
    pub name: String,
    /// 技能目录路径
    pub dir_path: String,
    /// 完整的解析后内容(含 $ARGUMENTS 替换、references/scripts 列表)
    pub resolved_content: String,
    /// 调用时间戳,单位:秒(用于 LRU 排序,最近调用的优先保留)
    pub invoked_at_secs: u64,
}

/// 会话内已调用技能的共享状态(Agent 线程写入,auto_compact 读取)
/// 使用 Arc<Mutex<HashMap>> 以便跨线程共享
pub type InvokedSkillsMap = Arc<Mutex<HashMap<String, InvokedSkill>>>;

/// 创建空的 InvokedSkillsMap
pub fn new_invoked_skills_map() -> InvokedSkillsMap {
    Arc::new(Mutex::new(HashMap::new()))
}

/// 记录一次技能调用(由 LoadSkill 工具执行后调用)
pub fn record_skill_invocation(
    map: &InvokedSkillsMap,
    name: String,
    dir_path: String,
    content: String,
) {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    if let Ok(mut skills) = map.lock() {
        let log_name = name.clone();
        skills.insert(
            name.clone(),
            InvokedSkill {
                name,
                dir_path,
                resolved_content: content,
                invoked_at_secs: now,
            },
        );
        write_info_log("invoked_skills", &format!("记录技能调用: {}", log_name));
    }
}

/// 构建 auto_compact 后需恢复的技能附件内容
/// 按最近调用时间排序,总预算 COMPACT_SKILL_TOKEN_BUDGET tokens,
/// 每个技能截断到 COMPACT_SKILL_PER_SKILL_TOKEN_BUDGET tokens
pub fn build_invoked_skills_attachment(map: &InvokedSkillsMap) -> Option<String> {
    let skills = map.lock().ok()?;
    if skills.is_empty() {
        return None;
    }

    // 按最近调用时间排序(新→旧)
    let mut sorted_by_recency: Vec<&InvokedSkill> = skills.values().collect();
    sorted_by_recency.sort_by(|a, b| b.invoked_at_secs.cmp(&a.invoked_at_secs));

    let mut result =
        String::from("Skills invoked in this session (preserved across compaction):\n\n");
    let mut total_tokens = 0usize;
    let per_skill_budget = COMPACT_SKILL_PER_SKILL_TOKEN_BUDGET;
    let total_budget = COMPACT_SKILL_TOKEN_BUDGET;

    for skill in sorted_by_recency {
        let skill_tokens = skill.resolved_content.len() / 4; // 粗略估算
        let available = if total_tokens + per_skill_budget > total_budget {
            total_budget.saturating_sub(total_tokens)
        } else {
            per_skill_budget
        };
        if available == 0 {
            break;
        }

        result.push_str(&format!("### Skill: {}\n", skill.name));
        result.push_str(&format!("Path: {}\n", skill.dir_path));

        if skill_tokens <= available {
            result.push_str(&skill.resolved_content);
            total_tokens += skill_tokens;
        } else {
            // 截断到 available tokens (~4 chars/token),保留头部(通常包含最关键的使用说明)
            let char_cutoff = available * 4;
            let truncated: String = skill.resolved_content.chars().take(char_cutoff).collect();
            result.push_str(&truncated);
            result.push_str("\n\n[... skill content truncated for compaction ...]");
            total_tokens += available;
        }
        result.push_str("\n\n---\n\n");
    }

    Some(result)
}

// ========== Compact 结果 ==========

/// auto_compact 执行结果
#[derive(Debug, Clone)]
pub struct CompactResult {
    /// 压缩前的消息数量
    pub messages_before: usize,
    /// 保存的 transcript 文件路径
    pub transcript_path: String,
    /// LLM 生成的摘要文本(供 tool result 显示)
    pub summary: String,
    /// 保留的最近 user 消息原文(供 UI 显示)
    pub recent_user_messages: Vec<ChatMessage>,
}

// ========== Compact 配置 ==========

/// Context compact 配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactConfig {
    /// 是否启用 context compact
    #[serde(default = "default_compact_enabled")]
    pub enabled: bool,
    /// 触发 auto_compact 的 token 阈值
    #[serde(default = "default_token_threshold")]
    pub token_threshold: usize,
    /// micro_compact 保留最近几个 tool result 不替换
    #[serde(default = "default_keep_recent")]
    pub keep_recent: usize,
    /// micro_compact 中不压缩的工具名称列表(用户可扩展,与内置 EXEMPT_TOOLS 合并)
    #[serde(default)]
    pub micro_compact_exempt_tools: Vec<String>,
}

fn default_compact_enabled() -> bool {
    true
}

fn default_token_threshold() -> usize {
    COMPACT_TOKEN_THRESHOLD
}

fn default_keep_recent() -> usize {
    COMPACT_KEEP_RECENT
}

impl Default for CompactConfig {
    fn default() -> Self {
        Self {
            enabled: default_compact_enabled(),
            token_threshold: default_token_threshold(),
            keep_recent: default_keep_recent(),
            micro_compact_exempt_tools: Vec::new(),
        }
    }
}

impl CompactConfig {
    /// 返回有效的压缩阈值;若用户未设置(=0)则使用编译期默认值。
    pub fn effective_token_threshold(&self) -> usize {
        if self.token_threshold == 0 {
            COMPACT_TOKEN_THRESHOLD
        } else {
            self.token_threshold
        }
    }
}

/// 粗略估算 messages 的 token 数(~4 chars per token)
pub fn estimate_tokens(messages: &[ChatMessage]) -> usize {
    serde_json::to_string(messages).unwrap_or_default().len() / 4
}

/// 提取 messages 中所有 role="user" 的消息(保留原始顺序)。
/// 用于 plan-clear 场景:清空 assistant 探索过程与 tool 结果,但保留用户的全部意图/追问。
pub fn extract_user_messages(messages: &[ChatMessage]) -> Vec<ChatMessage> {
    messages
        .iter()
        .filter(|m| m.role == ROLE_USER)
        .cloned()
        .collect()
}

/// 提取最近 N 条 user 消息原文(不限于未被回复的)。
/// 从末尾向前扫描,取最后 `count` 条 role=user 的消息,保留原始顺序。
/// 用于 auto_compact 场景:压缩后必须保留用户最近的消息原文,
/// 否则 LLM 只能看到摘要而丢失用户的精确措辞和当前任务意图。
pub fn extract_recent_user_messages(messages: &[ChatMessage], count: usize) -> Vec<ChatMessage> {
    let mut recent: Vec<ChatMessage> = Vec::new();
    for m in messages.iter().rev() {
        if m.role == ROLE_USER {
            recent.push(m.clone());
            if recent.len() >= count {
                break;
            }
        }
    }
    recent.reverse();
    recent
}

/// 内置豁免工具列表(不可配置,始终不压缩这些工具的返回结果)
pub const BUILTIN_EXEMPT_TOOLS: &[&str] = &[
    LoadSkillTool::NAME,
    TaskTool::NAME,
    TodoWriteTool::NAME,
    TodoReadTool::NAME,
    EnterPlanModeTool::NAME,
    ExitPlanModeTool::NAME,
    SubAgentTool::NAME,
    AgentTeamTool::NAME,
    AskTool::NAME,
    // Teammate 工具结果不压缩(承载协作上下文)
    crate::command::chat::tools::send_message::SendMessageTool::NAME,
    crate::command::chat::tools::create_teammate::CreateTeammateTool::NAME,
];

/// 判断工具名是否应被豁免(合并内置 + 用户扩展清单)
pub fn is_exempt_tool(tool_name: &str, extra_exempt_tools: &[String]) -> bool {
    BUILTIN_EXEMPT_TOOLS.contains(&tool_name) || extra_exempt_tools.iter().any(|t| t == tool_name)
}

/// Layer 1: micro_compact - 替换旧 tool result 为占位符,保留最近 keep_recent 个
///
/// 纯内存操作,零 API 成本。
/// 将较早的 role="tool" 消息中内容长度 > MICRO_COMPACT_BYTES_THRESHOLD 的替换为 "[Previous: used {tool_name}]"
pub fn micro_compact(
    messages: &mut [ChatMessage],
    keep_recent: usize,
    extra_exempt_tools: &[String],
) {
    // 1. 从 assistant 消息的 tool_calls 构建 tool_call_id → tool_name 映射
    let mut tool_call_id_to_name: HashMap<String, String> = HashMap::new();
    for msg in messages.iter() {
        if msg.role == ROLE_ASSISTANT
            && let Some(ref tool_calls) = msg.tool_calls
        {
            for tool_call in tool_calls {
                tool_call_id_to_name.insert(tool_call.id.clone(), tool_call.name.clone());
            }
        }
    }

    // 2. 找出所有 role="tool" 的消息索引
    let tool_indices: Vec<usize> = messages
        .iter()
        .enumerate()
        .filter(|(_, msg)| msg.role == ROLE_TOOL)
        .map(|(i, _)| i)
        .collect();

    if tool_indices.len() <= keep_recent {
        return;
    }

    // 3. 除最近 keep_recent 个外,content.len() > MICRO_COMPACT_BYTES_THRESHOLD 的替换为占位符
    let indices_to_compact = &tool_indices[..tool_indices.len() - keep_recent];
    let mut compacted_count = 0;

    for &idx in indices_to_compact {
        let msg = &messages[idx];
        if msg.content.chars().count() > MICRO_COMPACT_BYTES_THRESHOLD {
            let tool_call_id = msg.tool_call_id.clone().unwrap_or_default();
            let tool_name = tool_call_id_to_name
                .get(&tool_call_id)
                .cloned()
                .unwrap_or_else(|| "unknown".to_string());
            if is_exempt_tool(&tool_name, extra_exempt_tools) {
                continue;
            }
            messages[idx].content = format!("[Previous: used {}]", tool_name);
            compacted_count += 1;
        }
    }

    if compacted_count > 0 {
        write_info_log(
            "micro_compact",
            &format!(
                "压缩了 {} 个旧 tool result(保留最近 {} 个)",
                compacted_count, keep_recent
            ),
        );
    }
}

/// 保存完整 transcript 到 `sessions/<id>/.transcripts/` 目录
fn save_transcript(messages: &[ChatMessage], session_id: &str) -> Option<String> {
    let paths = SessionPaths::new(session_id);
    let transcript_dir = paths.transcripts_dir();
    if let Err(e) = fs::create_dir_all(&transcript_dir) {
        write_error_log(
            "save_transcript",
            &format!("创建 .transcripts 目录失败: {}", e),
        );
        return None;
    }

    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    let path = transcript_dir.join(format!("transcript_{}.jsonl", timestamp));

    let mut content = String::new();
    for msg in messages {
        if let Ok(line) = serde_json::to_string(msg) {
            content.push_str(&line);
            content.push('\n');
        }
    }

    match fs::write(&path, &content) {
        Ok(_) => {
            let path_str = path.display().to_string();
            write_info_log(
                "save_transcript",
                &format!("Transcript saved: {}", path_str),
            );
            Some(path_str)
        }
        Err(e) => {
            write_error_log("save_transcript", &format!("保存 transcript 失败: {}", e));
            None
        }
    }
}

/// Layer 2: auto_compact - 保存 transcript + LLM 摘要 + 替换消息
///
/// 需要调用 LLM(非流式,max_tokens=20000)。
/// 失败时 graceful degradation:log 错误,返回 Err,调用方可继续用原消息。
///
/// `invoked_skills`: 会话内已调用技能的共享状态,auto_compact 后将技能指令作为附件重新注入,
/// 确保模型在压缩后仍能遵循正在执行的技能/工作流。
pub async fn auto_compact(
    messages: &mut Vec<ChatMessage>,
    provider: &ModelProvider,
    invoked_skills: &InvokedSkillsMap,
    session_id: &str,
    protected_context: Option<&str>,
) -> Result<CompactResult, String> {
    // 记录压缩前的消息数(用于 UI 提示)
    let messages_before = messages.len();

    // 1. 保存 transcript 到 session 级 .transcripts/ 目录
    let transcript_path =
        save_transcript(messages, session_id).unwrap_or_else(|| "(unsaved)".to_string());

    // 2. 构建结构化摘要请求(9 段式模板,确保技能/工作流进度被保留)
    let conversation_text = serde_json::to_string(messages).unwrap_or_default();
    // 截断到 80000 chars
    let truncated_conversation_text: String = conversation_text
        .chars()
        .take(COMPACT_TRUNCATE_MAX_CHARS)
        .collect();

    let summary_prompt = format!(
        "Summarize this conversation for continuity. Use this structured format:\n\
         1) **Primary Request**: What the user originally asked for.\n\
         2) **Key Concepts**: Important technical concepts, domain knowledge, or constraints discovered.\n\
         3) **Files and Code**: Key files read or modified, with important code snippets or decisions.\n\
         4) **Errors and Fixes**: Any errors encountered and how they were resolved.\n\
         5) **Problem Solving**: Reasoning steps and approach taken.\n\
         6) **Active Skills/Workflows**: If a skill or workflow was being followed, list its name, key steps, and current progress. Include direct quotes showing exactly where you left off.\n\
         7) **Pending Tasks**: Things that still need to be done.\n\
         8) **Current Work**: What was being worked on most recently. Include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off.\n\
         9) **Next Step**: What should happen next to continue the work.\n\
         \n\
         Be concise but preserve critical details. Section 6 (Active Skills/Workflows) is especially important — preserve all skill instructions and progress so the model can continue following them without re-loading.\n\n\
         {}",
        truncated_conversation_text
    );

    // 追加保护指令(来自 PreAutoCompact hook 的 additional_context)
    let summary_prompt_with_context = if let Some(protected) = protected_context {
        format!(
            "{}\n\n[Protected Context — MUST preserve in full]:\n{}",
            summary_prompt, protected
        )
    } else {
        summary_prompt
    };

    let user_msg = ChatCompletionRequestUserMessageArgs::default()
        .content(summary_prompt_with_context.as_str())
        .build()
        .map_err(|e| format!("构建摘要请求消息失败: {}", e))?;

    let request = CreateChatCompletionRequestArgs::default()
        .model(&provider.model)
        .messages(vec![ChatCompletionRequestMessage::User(user_msg)])
        .max_tokens(20000u32)
        .build()
        .map_err(|e| format!("构建摘要请求失败: {}", e))?;

    // 3. 调用 LLM(非流式)
    let client = create_openai_client(provider);
    let response = client
        .chat()
        .create(request)
        .await
        .map_err(|e| format!("auto_compact LLM 请求失败: {}", e))?;

    let summary = response
        .choices
        .first()
        .and_then(|c| c.message.content.clone())
        .unwrap_or_else(|| "(empty summary)".to_string());

    write_info_log(
        "auto_compact",
        &format!("摘要完成,长度: {} chars", summary.len()),
    );

    // 4. 替换 messages 为 [summary_user_msg, understood_assistant_msg, ...recent_user_msgs]
    //    保留最近 N 条 user 消息原文,确保 LLM 下一轮能看到用户的精确措辞和当前任务
    let recent_user = extract_recent_user_messages(messages, COMPACT_KEEP_RECENT_USER_MESSAGES);
    messages.clear();
    let mut summary_content = format!(
        "[Conversation compressed. Transcript: {}]\n\n{}",
        transcript_path, summary
    );

    // 注入已调用技能附件(结构化保留,类似 Claude Code 的 invoked_skills 机制)
    if let Some(skills_attachment) = build_invoked_skills_attachment(invoked_skills) {
        summary_content.push_str(&format!(
            "\n\n<system-reminder>\n{}\n</system-reminder>",
            skills_attachment
        ));
        write_info_log(
            "auto_compact",
            "已注入 invoked_skills 附件,确保压缩后技能指令可继续遵循",
        );
    }

    messages.push(ChatMessage {
        role: "user".to_string(),
        content: summary_content,
        tool_calls: None,
        tool_call_id: None,
        images: None,
    });
    messages.push(ChatMessage {
        role: ROLE_ASSISTANT.to_string(),
        content: "Understood. I have the context from the summary and any active skill instructions. Continuing to follow them.".to_string(),
        tool_calls: None,
        tool_call_id: None,
        images: None,
    });

    // 追加最近 N 条 user 消息原文,确保 LLM 能看到用户的精确措辞
    let recent_user_clone = recent_user.clone();
    if !recent_user.is_empty() {
        write_info_log(
            "auto_compact",
            &format!(
                "保留最近 {} 条 user 消息原文,确保压缩后任务意图不丢失",
                recent_user.len()
            ),
        );
        for msg in recent_user {
            messages.push(msg);
        }
    }

    Ok(CompactResult {
        messages_before,
        transcript_path,
        summary,
        recent_user_messages: recent_user_clone,
    })
}