release: v0.91.0

This commit is contained in:
coso
2026-03-19 19:05:53 +08:00
parent ad8427be93
commit a414f44872
175 changed files with 10467 additions and 11013 deletions
@@ -5,139 +5,16 @@ use lime_core::database::dao::agent_timeline::{
AgentThreadTurnStatus, AgentTimelineDao,
};
use lime_core::database::{lock_db, DbConnection};
use serde_json::{json, Value};
use serde_json::Value;
use std::collections::HashMap;
use tauri::{AppHandle, Emitter};
const PROPOSED_PLAN_OPEN: &str = "<proposed_plan>";
const PROPOSED_PLAN_CLOSE: &str = "</proposed_plan>";
fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String]) -> String {
let mut lines = Vec::new();
let trimmed_title = title.trim();
if !trimmed_title.is_empty() {
lines.push(trimmed_title.to_string());
}
let trimmed_detail = detail.trim();
if !trimmed_detail.is_empty() {
lines.push(trimmed_detail.to_string());
}
for checkpoint in checkpoints {
let trimmed = checkpoint.trim();
if !trimmed.is_empty() {
lines.push(format!("• {trimmed}"));
}
}
lines.join("\n")
}
fn emit_event(app: &AppHandle, event_name: &str, event: &TauriAgentEvent) {
if let Err(error) = app.emit(event_name, event) {
tracing::error!("[AgentTimeline] 发送事件失败: {}", error);
}
}
fn as_object(value: &Value) -> Option<&serde_json::Map<String, Value>> {
value.as_object()
}
#[derive(Debug, Clone)]
struct ExtractedFileArtifact {
path: String,
artifact_id: Option<String>,
}
fn push_unique_file_path(target: &mut Vec<String>, raw: &str) {
let trimmed = raw.trim();
if trimmed.is_empty() || target.iter().any(|item| item == trimmed) {
return;
}
target.push(trimmed.to_string());
}
fn collect_string_values(value: &Value) -> Vec<String> {
match value {
Value::String(text) => {
let trimmed = text.trim();
if trimmed.is_empty() {
Vec::new()
} else {
vec![trimmed.to_string()]
}
}
Value::Array(items) => items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect(),
_ => Vec::new(),
}
}
fn extract_file_artifacts(
arguments: Option<&Value>,
metadata: Option<&Value>,
) -> Vec<ExtractedFileArtifact> {
let mut paths = Vec::new();
for source in [arguments, metadata] {
let Some(object) = source.and_then(as_object) else {
continue;
};
for key in [
"path",
"file_path",
"filePath",
"output_file",
"output_path",
"outputPath",
"artifact_path",
"artifact_paths",
"absolute_path",
"absolutePath",
] {
let Some(value) = object.get(key) else {
continue;
};
for path in collect_string_values(value) {
push_unique_file_path(&mut paths, path.as_str());
}
}
}
let metadata_object = metadata.and_then(as_object);
let artifact_ids = metadata_object
.and_then(|object| object.get("artifact_ids"))
.map(collect_string_values)
.unwrap_or_default();
let single_artifact_id = metadata_object
.and_then(|object| {
object
.get("artifact_id")
.or_else(|| object.get("artifactId"))
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
paths
.into_iter()
.enumerate()
.map(|(index, path)| ExtractedFileArtifact {
path,
artifact_id: artifact_ids.get(index).cloned().or_else(|| {
if index == 0 {
single_artifact_id.clone()
} else {
None
}
}),
})
.collect()
}
fn resolve_artifact_item_status(metadata: Option<&Value>) -> AgentThreadItemStatus {
let write_phase = metadata
.and_then(|value| value.get("writePhase"))
@@ -165,18 +42,6 @@ fn resolve_artifact_item_source(metadata: Option<&Value>) -> String {
.unwrap_or_else(|| "artifact_snapshot".to_string())
}
fn extract_proposed_plan_block(text: &str) -> Option<String> {
let start = text.find(PROPOSED_PLAN_OPEN)?;
let remainder = &text[start + PROPOSED_PLAN_OPEN.len()..];
let end = remainder.find(PROPOSED_PLAN_CLOSE)?;
let content = remainder[..end].trim();
if content.is_empty() {
None
} else {
Some(content.to_string())
}
}
#[derive(Debug)]
pub struct AgentTimelineRecorder {
db: DbConnection,
@@ -187,7 +52,6 @@ pub struct AgentTimelineRecorder {
item_sequences: HashMap<String, i64>,
item_statuses: HashMap<String, AgentThreadItemStatus>,
plan_text: Option<String>,
turn_summary_text: Option<String>,
}
impl AgentTimelineRecorder {
@@ -228,7 +92,6 @@ impl AgentTimelineRecorder {
item_sequences: HashMap::new(),
item_statuses: HashMap::new(),
plan_text: None,
turn_summary_text: None,
})
}
@@ -265,7 +128,6 @@ impl AgentTimelineRecorder {
item.clone(),
TauriAgentEvent::ItemStarted { item: item.clone() },
)?;
self.maybe_project_plan_item(app, event_name, item)?;
}
TauriAgentEvent::ItemUpdated { item } => {
self.persist_runtime_item(
@@ -274,7 +136,6 @@ impl AgentTimelineRecorder {
item.clone(),
TauriAgentEvent::ItemUpdated { item: item.clone() },
)?;
self.maybe_project_plan_item(app, event_name, item)?;
}
TauriAgentEvent::ItemCompleted { item } => {
self.persist_runtime_item(
@@ -283,52 +144,9 @@ impl AgentTimelineRecorder {
item.clone(),
TauriAgentEvent::ItemCompleted { item: item.clone() },
)?;
self.maybe_project_plan_item(app, event_name, item)?;
}
TauriAgentEvent::RuntimeStatus { status } => {
let text =
format_runtime_status_text(&status.title, &status.detail, &status.checkpoints);
if !text.is_empty() {
self.turn_summary_text = Some(text.clone());
let item = self.build_item(
format!("turn_summary:{}", self.turn_id),
AgentThreadItemStatus::InProgress,
None,
AgentThreadItemPayload::TurnSummary { text },
);
self.persist_and_emit_item(app, event_name, item)?;
}
}
TauriAgentEvent::ToolEnd { tool_id, result } => {
let metadata_value = result
.metadata
.as_ref()
.and_then(|metadata| serde_json::to_value(metadata).ok());
for artifact in extract_file_artifacts(None, metadata_value.as_ref()) {
let artifact_path = artifact.path.clone();
let status = resolve_artifact_item_status(metadata_value.as_ref());
let file_item = self.build_item(
artifact
.artifact_id
.clone()
.unwrap_or_else(|| format!("artifact:{}:{}", tool_id, artifact_path)),
status.clone(),
if matches!(status, AgentThreadItemStatus::InProgress) {
None
} else {
Some(Utc::now().to_rfc3339())
},
AgentThreadItemPayload::FileArtifact {
path: artifact_path,
source: "tool_result".to_string(),
content: None,
metadata: metadata_value.clone(),
},
);
self.persist_and_emit_item(app, event_name, file_item)?;
}
}
TauriAgentEvent::RuntimeStatus { .. } => {}
TauriAgentEvent::ToolEnd { .. } => {}
TauriAgentEvent::ArtifactSnapshot { artifact } => {
let metadata_value = artifact
.metadata
@@ -480,18 +298,6 @@ impl AgentTimelineRecorder {
self.persist_and_emit_item(app, event_name, item)?;
}
if let Some(turn_summary_text) = self.turn_summary_text.clone() {
let item = self.build_item(
format!("turn_summary:{}", self.turn_id),
status,
Some(Utc::now().to_rfc3339()),
AgentThreadItemPayload::TurnSummary {
text: turn_summary_text,
},
);
self.persist_and_emit_item(app, event_name, item)?;
}
Ok(())
}
@@ -592,98 +398,8 @@ impl AgentTimelineRecorder {
self.item_statuses
.insert(item.id.clone(), item.status.clone());
if let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload {
self.plan_text = extract_proposed_plan_block(text);
if let AgentThreadItemPayload::Plan { text } = &item.payload {
self.plan_text = Some(text.clone());
}
}
fn maybe_project_plan_item(
&mut self,
app: &AppHandle,
event_name: &str,
item: &AgentThreadItem,
) -> Result<(), String> {
let AgentThreadItemPayload::AgentMessage { text, .. } = &item.payload else {
return Ok(());
};
let Some(plan_text) = extract_proposed_plan_block(text) else {
return Ok(());
};
self.plan_text = Some(plan_text.clone());
let plan_item = self.build_item(
format!("plan:{}", self.turn_id),
item.status.clone(),
item.completed_at.clone(),
AgentThreadItemPayload::Plan { text: plan_text },
);
self.persist_and_emit_item(app, event_name, plan_item)?;
Ok(())
}
}
pub fn complete_action_item(
db: &DbConnection,
request_id: &str,
response: Option<Value>,
) -> Result<(), String> {
let conn = lock_db(db)?;
let Some(mut item) = AgentTimelineDao::get_item(&conn, request_id)
.map_err(|e| format!("读取 action item 失败: {e}"))?
else {
return Ok(());
};
let payload = match item.payload {
AgentThreadItemPayload::ApprovalRequest {
request_id,
action_type,
prompt,
tool_name,
arguments,
..
} => AgentThreadItemPayload::ApprovalRequest {
request_id,
action_type,
prompt,
tool_name,
arguments,
response,
},
AgentThreadItemPayload::RequestUserInput {
request_id,
action_type,
prompt,
questions,
..
} => AgentThreadItemPayload::RequestUserInput {
request_id,
action_type,
prompt,
questions,
response,
},
other => other,
};
let now = Utc::now().to_rfc3339();
item.status = AgentThreadItemStatus::Completed;
item.completed_at = Some(now.clone());
item.updated_at = now;
item.payload = payload;
AgentTimelineDao::upsert_item(&conn, &item).map_err(|e| format!("更新 action item 失败: {e}"))
}
pub fn build_action_response_value(
confirmed: bool,
response: Option<&str>,
user_data: Option<&Value>,
) -> Option<Value> {
if let Some(value) = user_data {
return Some(value.clone());
}
if !confirmed {
return Some(json!({ "confirmed": false }));
}
response.map(|value| Value::String(value.to_string()))
}
+3 -69
View File
@@ -1,5 +1,4 @@
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
use crate::database::load_pending_general_messages;
use chrono::{Local, TimeZone};
use rusqlite::Connection;
use std::collections::HashSet;
@@ -24,15 +23,6 @@ pub fn load_memory_source_candidates(
let mut candidates = Vec::new();
let mut seen = HashSet::new();
load_pending_general_candidates(
conn,
from_timestamp,
to_timestamp,
limit,
min_message_length,
&mut candidates,
&mut seen,
)?;
load_unified_general_candidates(
conn,
from_timestamp,
@@ -58,33 +48,6 @@ pub fn load_memory_source_candidates(
Ok(candidates)
}
fn load_pending_general_candidates(
conn: &Connection,
from_timestamp: Option<i64>,
to_timestamp: Option<i64>,
limit: usize,
min_message_length: usize,
candidates: &mut Vec<MemorySourceCandidate>,
seen: &mut HashSet<String>,
) -> Result<(), String> {
let rows = load_pending_general_messages(conn, from_timestamp, to_timestamp, limit)
.map_err(|e| format!("读取待迁移 general 消息失败: {e}"))?;
for row in rows {
push_candidate(
candidates,
seen,
row.session_id,
row.role,
row.content,
normalize_timestamp(row.created_at),
min_message_length,
);
}
Ok(())
}
fn load_unified_general_candidates(
conn: &Connection,
from_timestamp: Option<i64>,
@@ -274,7 +237,7 @@ mod tests {
}
#[test]
fn load_memory_source_candidates_merges_unified_and_legacy_without_duplicates() {
fn load_memory_source_candidates_only_reads_unified_general_and_agent_messages() {
let conn = Connection::open_in_memory().expect("open in memory db");
create_test_schema(&conn);
@@ -299,28 +262,6 @@ mod tests {
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params!["general-migrated", "旧会话", 1_741_744_000_000i64, 1_741_744_000_000i64],
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params!["legacy-only", "旧会话2", 1_741_744_100_000i64, 1_741_744_100_000i64],
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_messages (id, session_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params!["g1", "general-migrated", "user", "这条消息已经迁移", 1_741_744_000_000i64],
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_messages (id, session_id, role, content, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params!["g2", "legacy-only", "assistant", "这条消息仍在旧表中", 1_741_744_100_000i64],
)
.unwrap();
conn.execute(
"INSERT INTO agent_messages (session_id, role, content_json, timestamp) VALUES (?1, ?2, ?3, ?4)",
params![
@@ -349,23 +290,16 @@ mod tests {
.iter()
.map(|item| item.session_id.as_str())
.collect::<Vec<_>>();
assert_eq!(candidates.len(), 3);
assert_eq!(candidates.len(), 2);
assert!(session_ids.contains(&"general-migrated"));
assert!(session_ids.contains(&"legacy-only"));
assert!(session_ids.contains(&"agent-1"));
}
#[test]
fn load_memory_source_candidates_skips_legacy_general_after_migration_completed() {
fn load_memory_source_candidates_ignores_pending_general_tables() {
let conn = Connection::open_in_memory().expect("open in memory db");
create_test_schema(&conn);
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
params!["migrated_general_chat_to_unified", "true"],
)
.unwrap();
conn.execute(
"INSERT INTO agent_sessions (id, model, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params![
@@ -4,7 +4,7 @@
use crate::database::dao::agent::{AgentDao, AgentModelPatternMatch};
use crate::database::dao::orchestrator::OrchestratorDao;
use crate::database::{summarize_pending_general, ConversationWindowSummary};
use crate::database::ConversationWindowSummary;
use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
@@ -242,16 +242,12 @@ fn summarize_general_window(
from_timestamp_ms: Option<i64>,
to_timestamp_ms: Option<i64>,
) -> Result<ConversationWindowSummary, String> {
let unified = summarize_unified_window(
summarize_unified_window(
conn,
AgentModelPatternMatch::Like,
from_timestamp_ms,
to_timestamp_ms,
)?;
let pending = summarize_pending_general(conn, from_timestamp_ms, to_timestamp_ms)
.map_err(|e| format!("查询待迁移 general 摘要失败: {e}"))?;
Ok(unified.merge(pending))
)
}
fn summarize_agent_window(
@@ -298,6 +294,7 @@ fn build_conversation_stats(windows: ConversationWindowTriplet) -> ConversationS
}
}
#[cfg(test)]
fn query_general_chat_stats(
conn: &Connection,
today_start: &DateTime<Local>,
@@ -307,6 +304,7 @@ fn query_general_chat_stats(
.map(build_conversation_stats)
}
#[cfg(test)]
fn query_agent_chat_stats(
conn: &Connection,
today_start: &DateTime<Local>,
@@ -641,7 +639,7 @@ mod tests {
}
#[test]
fn stats_ignore_legacy_general_after_migration_completed() {
fn stats_ignore_legacy_general_tables_during_runtime() {
let conn = Connection::open_in_memory().expect("open in memory db");
create_test_schema(&conn);
@@ -651,12 +649,6 @@ mod tests {
.expect("build datetime");
let now_ms = now.timestamp_millis();
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
params!["migrated_general_chat_to_unified", "true"],
)
.unwrap();
conn.execute(
"INSERT INTO general_chat_sessions (id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
params!["legacy-only", "旧通用会话", now_ms, now_ms],
@@ -1,7 +1,7 @@
//! 记忆画像提示词服务
//! 记忆提示词装配服务
//!
//! 将设置页中的记忆画像(学习状态、擅长领域、解释偏好、难题偏好)
//! 转换为可注入到系统提示词中的统一指令片段。
//! 将设置页中的记忆画像与配置化记忆来源统一装配为可注入到 system prompt
//! 的单一记忆指令片段,避免调用方继续各自决定拼装顺序。
use lime_core::config::Config;
use std::path::Path;
@@ -11,6 +11,26 @@ use crate::services::memory_source_resolver_service::build_memory_sources_prompt
const MEMORY_PROFILE_PROMPT_MARKER: &str = "【用户记忆画像偏好】";
const MEMORY_SOURCE_PROMPT_MARKER: &str = "【记忆来源补充指令】";
#[derive(Debug, Clone, Copy, Default)]
pub struct MemoryPromptContext<'a> {
pub working_dir: Option<&'a Path>,
pub active_relative_path: Option<&'a str>,
}
impl<'a> MemoryPromptContext<'a> {
pub fn with_working_dir(working_dir: &'a Path) -> Self {
Self {
working_dir: Some(working_dir),
active_relative_path: None,
}
}
pub fn with_active_relative_path(mut self, active_relative_path: Option<&'a str>) -> Self {
self.active_relative_path = active_relative_path;
self
}
}
fn normalize_text(input: &str) -> Option<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
@@ -32,7 +52,7 @@ fn normalize_list(items: &[String]) -> Vec<String> {
/// 仅在以下条件满足时返回:
/// - 记忆功能已启用
/// - 至少有一项画像字段有值
pub fn build_memory_profile_prompt(config: &Config) -> Option<String> {
fn build_memory_profile_prompt(config: &Config) -> Option<String> {
let memory = &config.memory;
if !memory.enabled {
return None;
@@ -83,59 +103,73 @@ pub fn build_memory_profile_prompt(config: &Config) -> Option<String> {
Some(lines.join("\n"))
}
/// 合并基础系统提示词与记忆画像提示词
///
/// - 已包含画像标记时不会重复追加
/// - 任一方为空时返回另一方
pub fn merge_system_prompt_with_memory_profile(
base_prompt: Option<String>,
fn build_memory_sources_prompt_for_context(
config: &Config,
context: MemoryPromptContext<'_>,
) -> Option<String> {
let memory_prompt = build_memory_profile_prompt(config);
let working_dir = context.working_dir?;
if !config.memory.enabled {
return None;
}
match (base_prompt, memory_prompt) {
(Some(base), Some(memory)) => {
if base.contains(MEMORY_PROFILE_PROMPT_MARKER) {
build_memory_sources_prompt(config, working_dir, context.active_relative_path, 4000)
}
fn merge_prompt_section(
base_prompt: Option<String>,
section_prompt: Option<String>,
marker: &str,
) -> Option<String> {
match (base_prompt, section_prompt) {
(Some(base), Some(section)) => {
if base.contains(marker) {
Some(base)
} else if base.trim().is_empty() {
Some(memory)
Some(section)
} else {
Some(format!("{base}\n\n{memory}"))
Some(format!("{base}\n\n{section}"))
}
}
(Some(base), None) => Some(base),
(None, Some(memory)) => Some(memory),
(None, Some(section)) => Some(section),
(None, None) => None,
}
}
pub fn merge_system_prompt_with_memory_sources(
pub fn build_memory_prompt(config: &Config, context: MemoryPromptContext<'_>) -> Option<String> {
let with_profile = merge_prompt_section(
None,
build_memory_profile_prompt(config),
MEMORY_PROFILE_PROMPT_MARKER,
);
merge_prompt_section(
with_profile,
build_memory_sources_prompt_for_context(config, context),
MEMORY_SOURCE_PROMPT_MARKER,
)
}
/// 合并基础系统提示词与统一记忆提示词。
///
/// - 画像与来源统一在同一边界内拼装
/// - 已包含对应 marker 时不会重复追加
pub fn merge_system_prompt_with_memory_context(
base_prompt: Option<String>,
config: &Config,
working_dir: &Path,
active_relative_path: Option<&str>,
context: MemoryPromptContext<'_>,
) -> Option<String> {
if !config.memory.enabled {
return base_prompt;
}
let with_profile = merge_prompt_section(
base_prompt,
build_memory_profile_prompt(config),
MEMORY_PROFILE_PROMPT_MARKER,
);
let memory_sources_prompt =
build_memory_sources_prompt(config, working_dir, active_relative_path, 4000);
match (base_prompt, memory_sources_prompt) {
(Some(base), Some(source_prompt)) => {
if base.contains(MEMORY_SOURCE_PROMPT_MARKER) {
Some(base)
} else if base.trim().is_empty() {
Some(source_prompt)
} else {
Some(format!("{base}\n\n{source_prompt}"))
}
}
(Some(base), None) => Some(base),
(None, Some(source_prompt)) => Some(source_prompt),
(None, None) => None,
}
merge_prompt_section(
with_profile,
build_memory_sources_prompt_for_context(config, context),
MEMORY_SOURCE_PROMPT_MARKER,
)
}
#[cfg(test)]
@@ -192,7 +226,11 @@ mod tests {
config.memory.profile = Some(profile);
let base = Some("前置内容\n\n【用户记忆画像偏好】\n已有内容".to_string());
let merged = merge_system_prompt_with_memory_profile(base.clone(), &config);
let merged = merge_system_prompt_with_memory_context(
base.clone(),
&config,
MemoryPromptContext::default(),
);
assert_eq!(merged, base);
}
@@ -210,10 +248,40 @@ mod tests {
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
config.memory.sources.project_rule_dirs = Vec::new();
let merged = merge_system_prompt_with_memory_sources(None, &config, tmp.path(), None)
.expect("should build sources prompt");
let merged = merge_system_prompt_with_memory_context(
None,
&config,
MemoryPromptContext::with_working_dir(tmp.path()),
)
.expect("should build sources prompt");
assert!(merged.contains("【记忆来源补充指令】"));
assert!(merged.contains("偏好简洁输出"));
}
#[test]
fn should_build_combined_memory_prompt() {
let tmp = TempDir::new().expect("create temp dir");
fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 保持简洁")
.expect("write memory file");
let mut config = Config::default();
config.memory.enabled = true;
let mut profile = config.memory.profile.clone().unwrap_or_default();
profile.current_status = Some("高级开发者".to_string());
config.memory.profile = Some(profile);
config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()];
config.memory.sources.project_rule_dirs = Vec::new();
config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string());
config.memory.sources.user_memory_path = Some("missing-user.md".to_string());
let prompt =
build_memory_prompt(&config, MemoryPromptContext::with_working_dir(tmp.path()))
.expect("should build combined prompt");
assert!(prompt.contains("【用户记忆画像偏好】"));
assert!(prompt.contains("高级开发者"));
assert!(prompt.contains("【记忆来源补充指令】"));
assert!(prompt.contains("保持简洁"));
}
}
+193 -73
View File
@@ -125,6 +125,12 @@ pub struct EnvironmentDiagnostics {
pub supplemental_search_dirs: Vec<String>,
#[serde(default)]
pub supplemental_command_candidates: Vec<String>,
#[serde(default)]
pub git_where_candidates: Vec<String>,
#[serde(default)]
pub git_supplemental_search_dirs: Vec<String>,
#[serde(default)]
pub git_supplemental_command_candidates: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -4089,23 +4095,30 @@ fn find_command_in_bin_dir(command_name: &str, bin_dir: &Path) -> Option<PathBuf
))
}
async fn collect_preferred_runtime_command_dirs(
command_name: &str,
preferred_bin_dir: &Path,
) -> Result<Vec<PathBuf>, String> {
fn collect_existing_unique_dirs<I>(candidates: I) -> Vec<PathBuf>
where
I: IntoIterator<Item = PathBuf>,
{
let mut dirs = Vec::new();
let mut seen = HashSet::new();
let mut push_dir = |dir: PathBuf| {
for dir in candidates {
if dir.as_os_str().is_empty() || !dir.exists() {
return;
continue;
}
if seen.insert(dir.clone()) {
dirs.push(dir);
}
};
}
push_dir(preferred_bin_dir.to_path_buf());
dirs
}
async fn collect_preferred_runtime_command_dirs(
command_name: &str,
preferred_bin_dir: &Path,
) -> Result<Vec<PathBuf>, String> {
let mut candidate_dirs = vec![preferred_bin_dir.to_path_buf()];
if command_name == "openclaw" {
if let Some(npm_path) = find_command_in_bin_dir("npm", preferred_bin_dir)
@@ -4113,13 +4126,13 @@ async fn collect_preferred_runtime_command_dirs(
{
if let Some(prefix) = detect_npm_global_prefix(&npm_path).await {
for dir in npm_global_command_dirs(&prefix) {
push_dir(dir);
candidate_dirs.push(dir);
}
}
}
}
Ok(dirs)
Ok(collect_existing_unique_dirs(candidate_dirs))
}
async fn collect_preferred_runtime_command_candidates(
@@ -4252,9 +4265,17 @@ async fn select_command_candidate(
return select_node_runtime_candidate(candidates).await;
}
if command_name == "git" {
return Ok(select_best_git_candidate(candidates));
}
Ok(candidates.into_iter().next())
}
fn select_best_git_candidate(candidates: Vec<PathBuf>) -> Option<PathBuf> {
select_preferred_path_candidate(candidates.clone()).or_else(|| candidates.into_iter().next())
}
async fn select_preferred_runtime_candidate(
command_name: &str,
candidates: &[PathBuf],
@@ -4287,68 +4308,61 @@ async fn select_preferred_runtime_candidate(
}
fn find_all_commands_in_known_locations(command_name: &str) -> Vec<PathBuf> {
let search_dirs = collect_known_command_search_dirs();
let search_dirs = collect_known_command_search_dirs(command_name);
find_all_commands_in_paths(command_name, &search_dirs)
}
fn collect_known_command_search_dirs() -> Vec<PathBuf> {
fn collect_known_command_search_dirs(_command_name: &str) -> Vec<PathBuf> {
let mut search_dirs = Vec::new();
let mut seen = HashSet::new();
let mut push_dir = |dir: PathBuf| {
if dir.as_os_str().is_empty() || !dir.exists() {
return;
}
if seen.insert(dir.clone()) {
search_dirs.push(dir);
}
};
if let Some(path_var) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path_var) {
push_dir(dir);
}
search_dirs.extend(std::env::split_paths(&path_var));
}
if let Some(home) = home_dir() {
push_dir(home.join(".npm-global/bin"));
push_dir(home.join(".local/bin"));
push_dir(home.join(".bun/bin"));
push_dir(home.join(".volta/bin"));
push_dir(home.join(".asdf/shims"));
push_dir(home.join(".local/share/mise/shims"));
push_dir(home.join("Library/PhpWebStudy/env/node/bin"));
search_dirs.extend([
home.join(".npm-global/bin"),
home.join(".local/bin"),
home.join(".bun/bin"),
home.join(".volta/bin"),
home.join(".asdf/shims"),
home.join(".local/share/mise/shims"),
home.join("Library/PhpWebStudy/env/node/bin"),
]);
let nvm_versions = home.join(".nvm/versions/node");
if let Ok(entries) = std::fs::read_dir(nvm_versions) {
for entry in entries.flatten() {
push_dir(entry.path().join("bin"));
search_dirs.push(entry.path().join("bin"));
}
}
let fnm_versions = home.join(".fnm/node-versions");
if let Ok(entries) = std::fs::read_dir(fnm_versions) {
for entry in entries.flatten() {
push_dir(entry.path().join("installation/bin"));
search_dirs.push(entry.path().join("installation/bin"));
}
}
}
#[cfg(target_os = "windows")]
{
for dir in windows_known_command_dirs_from_env() {
push_dir(dir);
search_dirs.extend(windows_known_command_dirs_from_env());
if _command_name == "git" {
search_dirs.extend(windows_known_git_command_dirs_from_env());
}
}
if cfg!(target_os = "macos") {
push_dir(PathBuf::from("/opt/homebrew/bin"));
push_dir(PathBuf::from("/usr/local/bin"));
push_dir(PathBuf::from("/usr/bin"));
push_dir(PathBuf::from("/bin"));
search_dirs.extend([
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/usr/bin"),
PathBuf::from("/bin"),
]);
}
search_dirs
collect_existing_unique_dirs(search_dirs)
}
#[cfg(target_os = "windows")]
@@ -4386,17 +4400,64 @@ fn windows_known_command_dirs_from_env() -> Vec<PathBuf> {
dirs
}
fn find_all_commands_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
let candidates = [
format!("{command_name}.exe"),
format!("{command_name}.cmd"),
format!("{command_name}.bat"),
command_name.to_string(),
];
#[cfg(any(target_os = "windows", test))]
fn windows_git_install_dir_variants(root: PathBuf) -> Vec<PathBuf> {
vec![
root.join("cmd"),
root.join("bin"),
root.join("mingw64").join("bin"),
]
}
#[cfg(not(target_os = "windows"))]
let candidates = [command_name.to_string()];
#[cfg(target_os = "windows")]
fn windows_known_git_command_dirs_from_env() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(program_files) = std::env::var_os("ProgramFiles") {
dirs.extend(windows_git_install_dir_variants(
PathBuf::from(program_files).join("Git"),
));
}
if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") {
dirs.extend(windows_git_install_dir_variants(
PathBuf::from(program_files_x86).join("Git"),
));
}
if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") {
dirs.extend(windows_git_install_dir_variants(
PathBuf::from(localappdata).join("Programs").join("Git"),
));
}
if let Some(home) = home_dir() {
dirs.extend(windows_git_install_dir_variants(
home.join("scoop").join("apps").join("git").join("current"),
));
}
dirs
}
fn find_all_commands_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Vec<PathBuf> {
find_all_commands_in_paths_for(current_shell_platform(), command_name, search_dirs)
}
fn find_all_commands_in_paths_for(
platform: ShellPlatform,
command_name: &str,
search_dirs: &[PathBuf],
) -> Vec<PathBuf> {
let candidates = match platform {
ShellPlatform::Windows => vec![
format!("{command_name}.exe"),
format!("{command_name}.cmd"),
format!("{command_name}.bat"),
command_name.to_string(),
],
ShellPlatform::Unix => vec![command_name.to_string()],
};
let mut matches = Vec::new();
let mut seen = HashSet::new();
@@ -4844,6 +4905,24 @@ async fn collect_environment_diagnostics() -> EnvironmentDiagnostics {
.and_then(find_installed_openclaw_package_details)
.map(|package| package.path.display().to_string());
#[cfg(target_os = "windows")]
let git_where_candidates = find_commands_via_where("git")
.await
.unwrap_or_default()
.into_iter()
.map(|path| path.display().to_string())
.collect();
#[cfg(not(target_os = "windows"))]
let git_where_candidates = Vec::new();
let git_supplemental_search_dirs = collect_supplemental_git_search_dirs();
let git_supplemental_command_candidates =
find_all_commands_in_paths("git", &git_supplemental_search_dirs)
.into_iter()
.map(|path| path.display().to_string())
.collect();
EnvironmentDiagnostics {
npm_path,
npm_global_prefix,
@@ -4854,36 +4933,40 @@ async fn collect_environment_diagnostics() -> EnvironmentDiagnostics {
.map(|path| path.display().to_string())
.collect(),
supplemental_command_candidates,
git_where_candidates,
git_supplemental_search_dirs: git_supplemental_search_dirs
.into_iter()
.map(|path| path.display().to_string())
.collect(),
git_supplemental_command_candidates,
}
}
fn collect_supplemental_openclaw_search_dirs(npm_global_prefix: Option<&str>) -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut seen = HashSet::new();
let mut push_dir = |dir: PathBuf| {
if dir.as_os_str().is_empty() || !dir.exists() {
return;
}
if seen.insert(dir.clone()) {
dirs.push(dir);
}
};
#[cfg(target_os = "windows")]
{
for dir in windows_known_command_dirs_from_env() {
push_dir(dir);
}
dirs.extend(windows_known_command_dirs_from_env());
}
if let Some(prefix) = npm_global_prefix {
for dir in npm_global_command_dirs(prefix) {
push_dir(dir);
}
dirs.extend(npm_global_command_dirs(prefix));
}
dirs
collect_existing_unique_dirs(dirs)
}
fn collect_supplemental_git_search_dirs() -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
return collect_existing_unique_dirs(windows_known_git_command_dirs_from_env());
}
#[cfg(not(target_os = "windows"))]
{
Vec::new()
}
}
async fn select_best_node_candidate(candidates: Vec<PathBuf>) -> Result<Option<PathBuf>, String> {
@@ -5263,15 +5346,15 @@ mod tests {
npm_global_node_modules_dirs_for, package_registry_for_package_spec,
parse_semver_from_text, resolve_openclaw_cli_entry_from_package_manifest,
resolve_openclaw_command_from_runtime_candidate, resolve_windows_dependency_install_plan,
runtime_candidate_matches_install_root, sanitize_runtime_config,
runtime_candidate_matches_install_root, sanitize_runtime_config, select_best_git_candidate,
select_best_semver_candidate, select_gateway_start_failure_detail,
select_openclaw_update_failure_detail, select_preferred_path_candidate,
shell_command_escape_for, shell_npm_prefix_assignment_for, shell_path_assignment_for,
trim_trailing_slash, windows_dependency_action_result, windows_dependency_setup_message,
windows_install_block_result, windows_manual_install_message, DependencyKind,
DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate,
ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN,
OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
windows_git_install_dir_variants, windows_install_block_result,
windows_manual_install_message, DependencyKind, DependencyStatus, EnvironmentDiagnostics,
OpenClawRuntimeCandidate, ResolvedOpenClawCommand, ShellPlatform,
WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE,
};
use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup};
use chrono::Utc;
@@ -5924,6 +6007,43 @@ mod tests {
);
}
#[test]
fn git_candidate_selection_prefers_executable_extension() {
let preferred = select_best_git_candidate(vec![
PathBuf::from(r"C:\Program Files\Git\cmd\git.cmd"),
PathBuf::from(r"C:\Program Files\Git\cmd\git.exe"),
]);
assert_eq!(
preferred,
Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe"))
);
}
#[test]
fn windows_git_install_dir_variants_cover_common_layouts() {
let git_root = build_unique_temp_dir("git-layout-root");
let cmd_dir = git_root.join("cmd");
let bin_dir = git_root.join("bin");
fs::create_dir_all(&cmd_dir).unwrap();
fs::create_dir_all(&bin_dir).unwrap();
fs::write(cmd_dir.join("git.exe"), "").unwrap();
fs::write(bin_dir.join("git.cmd"), "").unwrap();
let matches = super::find_all_commands_in_paths_for(
ShellPlatform::Windows,
"git",
&windows_git_install_dir_variants(git_root.clone()),
);
let _ = fs::remove_dir_all(&git_root);
assert_eq!(
matches,
vec![cmd_dir.join("git.exe"), bin_dir.join("git.cmd")]
);
}
#[test]
fn windows_npm_global_command_dirs_use_prefix_root() {
assert_eq!(