diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a0df6..d98d383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.7.3] - 2026-07-01 + +### Added +- 工具结果外置到磁盘,配合 `tool.recall` 按需回取原始内容,降低长对话上下文占用 +- `thought.read` 工具,读取想法正文与元数据,并带隐私过滤 +- web_research skill 增强为方案调研(solution research)能力 +- Agent loop 提取并固定任务上下文为 system message,循环告警时重锚定到已固定目标 +- 单条工具结果截断阈值 + 模型上下文窗口自动推断 +- 流式工具结果前置摘要,并增强 SSE 流健壮性 + +### Changed +- RESEARCH 提示词改为保存完整调研结果(含来源与分析),而非精简摘要;并优先使用 skill 而非裸工具 +- 上下文摘要触发更早,改为累积合并 +- ContextGuard 对工具结果降级而非直接删除;以 `summarized_up_to` 作为预摘要缓存键 +- 提高 web_research 超时以匹配更高的工具预算 +- 澄清 `note.create` 与 `thought.create` 的工具描述差异 + +### Fixed +- 聊天窗口仅在吸附底部时自动滚动,向上翻阅历史时不再被流式刷新打断 +- Agent loop 采用取消感知的指数退避重试 +- 每个工具独立超时,避免 skill 子轮次被提前终止 +- 在 OpenAI API 边界映射工具名,规避函数名不允许点号的限制 + +### Security +- 阻止 `note.read` 通过软链接逃逸出工作区 +- 链接推荐与主题网络图谱均过滤 kf-private 私有笔记 + +### Removed +- 清理死代码:provider 层的 `is_remote` 与 `create_provider_by_id` + ## [0.6.0] - 2026-05-25 ### Added diff --git a/package.json b/package.json index b77008b..ab969dc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "knowforge", "private": true, - "version": "0.7.2", + "version": "0.7.3", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f944a04..7e91742 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2664,7 +2664,7 @@ dependencies = [ [[package]] name = "knowforge" -version = "0.7.2" +version = "0.7.3" dependencies = [ "aho-corasick", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8a426a4..2502b56 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "knowforge" -version = "0.7.2" +version = "0.7.3" description = "Knowforge desktop app" authors = ["caichangqing"] edition = "2024" diff --git a/src-tauri/src/link_recommendation.rs b/src-tauri/src/link_recommendation.rs index 1448a18..a49bae6 100644 --- a/src-tauri/src/link_recommendation.rs +++ b/src-tauri/src/link_recommendation.rs @@ -406,9 +406,10 @@ fn read_note_markdown_for_overlap(vault_root: &Path, rel_key: &str, chunks: &[Do if let Ok(abs) = crate::join_under_root(vault_root, &norm) { if abs.is_file() { if let Ok(s) = fs::read_to_string(&abs) { - if !note_privacy::markdown_treat_as_kf_private(&s) { - return s; + if note_privacy::markdown_treat_as_kf_private(&s) { + return String::new(); } + return s; } } } @@ -507,6 +508,10 @@ pub fn suggest_related_notes( let mut out: Vec = Vec::new(); for (target_rel_path, sim) in scored.into_iter().take(max_results) { + let target_abs = vault_root.join(&target_rel_path); + if note_privacy::peek_kf_private_from_md_file(&target_abs) { + continue; + } let target_md = read_note_markdown_for_overlap(vault_root, &target_rel_path, &all_chunks); let target_plain = prepare_plain_for_overlap(&target_md); let target_tf = term_frequencies(&target_plain); @@ -946,4 +951,52 @@ mod tests { .unwrap(); assert!(c[0].reason.is_none()); } + + #[test] + fn suggest_related_filters_private_candidate() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("cur.md"), "x\n").unwrap(); + fs::write( + root.join("private.md"), + "---\nkf-private: true\n---\nsecret\n", + ) + .unwrap(); + fs::write(root.join("public.md"), "p\n").unwrap(); + + let conn = semantic_index::open_embedding_db(root).expect("open embedding db"); + let hi = vec![1.0_f32, 0.0, 0.0]; + let near = vec![0.95_f32, 0.05, 0.0]; + semantic_index::upsert_doc_chunk(&conn, "cur.md#0", "cur.md", 0, "t", &hi, "m").unwrap(); + semantic_index::upsert_doc_chunk(&conn, "private.md#0", "private.md", 0, "t", &near, "m") + .unwrap(); + semantic_index::upsert_doc_chunk(&conn, "public.md#0", "public.md", 0, "t", &near, "m") + .unwrap(); + + let tconn = Connection::open_in_memory().unwrap(); + let ec = semantic_index::EmbeddingCache::new(); + let rec = suggest_related_notes(root, "cur.md", &conn, &tconn, 5, None, &ec).unwrap(); + assert!( + rec.iter().all(|r| r.target_rel_path != "private.md"), + "private candidate must not appear: {rec:?}" + ); + assert!(rec.iter().any(|r| r.target_rel_path == "public.md")); + } + + #[test] + fn read_note_markdown_for_overlap_returns_empty_for_private() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("secret.md"), + "---\nkf-private: true\n---\nContent that must not leak\n", + ) + .unwrap(); + + let result = super::read_note_markdown_for_overlap(root, "secret.md", &[]); + assert!( + result.is_empty(), + "private note must return empty string, got: {result}" + ); + } } diff --git a/src-tauri/src/llm/agent_loop.rs b/src-tauri/src/llm/agent_loop.rs index 9f5809f..5c61ab6 100644 --- a/src-tauri/src/llm/agent_loop.rs +++ b/src-tauri/src/llm/agent_loop.rs @@ -17,7 +17,7 @@ use tokio_util::sync::CancellationToken; use super::approval::ToolApprovalState; use super::context_guard::{ContextGuard, PrecomputedSummary}; use super::memory; -use super::provider::{LlmProvider, NormalizedToolCall}; +use super::provider::{CompletionOverrides, LlmProvider, NormalizedToolCall}; use super::tool_result_processor::{self, ToolResultProcessor}; use super::{LlmChatMessage, LlmToolCall, LlmToolCallFunction}; use crate::tools::context::ToolContextFactory; @@ -36,11 +36,18 @@ pub(crate) type SharedMemoryManager = pub(crate) const TOOL_USE_DISCOVERY_HINT: &str = "TOOL USE: When the user references a file by partial name or unclear location, \ FIRST call `note.list` or `vault.search_keyword` to locate the actual rel_path, \ THEN call `note.read`. Never assume a file lives at the workspace root. \ +THOUGHTS: Use `thought.list` to discover thought IDs, then `thought.read` to retrieve full body and metadata. \ When a read or write tool returns NotFound, immediately try discovery (list/search) before guessing another path. \ WEB: When the user provides a specific URL (http/https link), always use `web.read_page` with that URL. \ Only use `web.search` when no URL is given and you need to find relevant pages by keyword. \ PDF: When `web.read_page` results mention a PDF link or the page is an academic paper with a PDF download, \ immediately call `web.read_pdf` with the PDF URL to extract the full text — do NOT tell the user to download it themselves. \ +RESEARCH: When the user explicitly asks for research, investigation, technology comparison, or solution analysis \ +(not a quick lookup or fact check), use `skill.web_research` — it produces a structured report and auto-saves to the \ +knowledge base. For simple queries (check a URL, verify a fact, find one piece of info), use raw tools directly. \ +If you used `web.search` / `web.read_page` directly for a substantial research task (4+ calls), call `note.create` to \ +save the complete research findings (full details, sources, and analysis — not a condensed summary) to \ +`research/{topic-keyword}.md` before reporting results. \ RESULT MATCHING: Each tool result is prefixed with [call:ID] to help you match results to calls when the same tool is invoked multiple times. \ RECALL: When a tool result shows [summarized from N chars | ref:XXX], the full raw content is stored on disk. \ If the summary lacks detail you need, call `tool.recall` with that ref ID to retrieve the original content."; @@ -113,6 +120,59 @@ pub(crate) async fn store_extraction_msgs(mm: &SharedMemoryManager, msgs: &[LlmC } } +async fn extract_task_context( + provider: &Arc, + messages: &[LlmChatMessage], +) -> Option { + let conversation: String = messages + .iter() + .rev() + .filter(|m| { + (m.role == "user" || m.role == "assistant") && !m.content.is_empty() + }) + .take(6) + .collect::>() + .into_iter() + .rev() + .map(|m| { + let truncated = tool_result_processor::truncate_at_boundary(&m.content, 500); + format!("[{}]: {}", m.role, truncated) + }) + .collect::>() + .join("\n"); + + if conversation.is_empty() { + return None; + } + + let extraction_messages = vec![ + LlmChatMessage { + role: "system".to_string(), + content: TASK_EXTRACT_PROMPT.to_string(), + ..Default::default() + }, + LlmChatMessage { + role: "user".to_string(), + content: conversation, + ..Default::default() + }, + ]; + + let overrides = CompletionOverrides { + temperature: Some(0.0), + ..Default::default() + }; + + match provider.chat_completion(&extraction_messages, Some(&overrides)).await { + Ok(text) if text.trim().len() >= 10 => Some(text.trim().to_string()), + Ok(_) => None, + Err(e) => { + eprintln!("[agent_loop] task context extraction failed: {}", e); + None + } + } +} + /// 启动 Tool Calling Loop。当 LLM 不再返回 tool_calls 时正常结束并 emit `llm:agent-done`。 /// /// Returns the **final assistant text** — the content from the iteration that ended without @@ -147,6 +207,8 @@ pub async fn run_agent_stream( }; let mut loop_detector = LoopDetector::new(); let mut pending_summary: Option>> = None; + let mut goal_extracted = false; + let mut pending_goal: Option>> = None; let results_dir = if config.nesting_depth == 0 { Some(workspace_root.join(".knowforge").join("tool-results")) @@ -195,6 +257,35 @@ pub async fn run_agent_stream( } } + if let Some(handle) = pending_goal.take() { + match handle.await { + Ok(Some(extracted)) => { + let content = format!("{}\n{}", TASK_CONTEXT_HEADER, extracted); + replace_or_insert_task_context(&mut messages, &content); + eprintln!( + "[agent_loop] session={} task context injected ({} chars)", + &session_id[..8.min(session_id.len())], extracted.len(), + ); + } + Ok(None) => { + if let Some(raw) = tool_result_processor::extract_user_goal(&messages) { + let content = format!("{}\nGOAL: {}", TASK_CONTEXT_HEADER, raw); + replace_or_insert_task_context(&mut messages, &content); + eprintln!( + "[agent_loop] session={} task context fallback (raw goal)", + &session_id[..8.min(session_id.len())], + ); + } + } + Err(e) => { + eprintln!( + "[agent_loop] session={} goal extraction task panicked: {}", + &session_id[..8.min(session_id.len())], e, + ); + } + } + } + if let Some(handle) = pending_summary.take() { if let Ok(Some(cached)) = handle.await { context_guard.apply_cached_summary(&mut messages, &cached); @@ -454,16 +545,43 @@ pub async fn run_agent_stream( } if any_looped { + let mut warning = String::from( + "WARNING: One or more tool calls were skipped because the same tool \ + was called repeatedly with identical arguments. Vary your approach \ + or use a different tool." + ); + if let Some(goal_msg) = messages.iter().find( + |m| m.role == "system" && m.content.starts_with(TASK_CONTEXT_HEADER) + ) { + let goal_body = goal_msg.content + .strip_prefix(TASK_CONTEXT_HEADER) + .unwrap_or(&goal_msg.content) + .trim(); + if !goal_body.is_empty() { + warning.push_str("\n\nReminder — your original task:\n"); + warning.push_str(goal_body); + } + } messages.push(LlmChatMessage { role: "system".to_string(), - content: "WARNING: One or more tool calls were skipped because the same tool \ - was called repeatedly with identical arguments. Vary your approach \ - or use a different tool." - .to_string(), + content: warning, ..Default::default() }); } + if !goal_extracted && config.nesting_depth == 0 { + goal_extracted = true; + let provider_clone = provider.clone(); + let msgs_snapshot = messages.clone(); + pending_goal = Some(tokio::spawn(async move { + extract_task_context(&provider_clone, &msgs_snapshot).await + })); + eprintln!( + "[agent_loop] session={} iter={} goal extraction spawned", + &session_id[..8.min(session_id.len())], iteration, + ); + } + // 6b. Reload memory if any memory.* tool was called if normalized_calls.iter().any(|tc| tc.name.starts_with("memory.")) { if let Some(ref mm) = memory_manager { @@ -534,7 +652,7 @@ pub async fn run_agent_stream( } let pressure = context_guard.budget_pressure(&messages); - if pressure > 0.5 { + if pressure > 0.4 { eprintln!( "[agent_loop] session={} iter={} context pressure={:.2}, pre-summarizing", &session_id[..8.min(session_id.len())], iteration, pressure, @@ -548,6 +666,35 @@ pub async fn run_agent_stream( } } +async fn retry_with_backoff( + mut result: crate::tools::types::ToolResult, + cancel: &CancellationToken, + mut invoke: F, +) -> crate::tools::types::ToolResult +where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + if let crate::tools::types::ToolResult::Err { ref error } = result { + if error.retryable { + for delay in [2u64, 4] { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(delay)) => {} + _ = cancel.cancelled() => {} + } + if cancel.is_cancelled() { + break; + } + result = invoke().await; + if !matches!(&result, crate::tools::types::ToolResult::Err { error } if error.retryable) { + break; + } + } + } + } + result +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn execute_tool( app: &AppHandle, @@ -654,12 +801,7 @@ pub(crate) async fn execute_tool( let mut result = tool.invoke(&ctx, tc.arguments.clone()).await; - if let crate::tools::types::ToolResult::Err { ref error } = result { - if error.retryable { - tokio::time::sleep(Duration::from_secs(2)).await; - result = tool.invoke(&ctx, tc.arguments.clone()).await; - } - } + result = retry_with_backoff(result, cancel, || tool.invoke(&ctx, tc.arguments.clone())).await; let duration_ms = start.elapsed().as_millis() as u64; @@ -850,6 +992,30 @@ fn summarize_tool_input(args: &Value) -> String { s } +const TASK_CONTEXT_HEADER: &str = "# Task Context"; + +const TASK_EXTRACT_PROMPT: &str = "\ +From the conversation below, extract the user's task: + +GOAL: What the user wants to accomplish (one sentence, imperative form) +CONSTRAINTS: Any restrictions, requirements, or preferences the user specified (bullet list, or 'none') + +Examples: + +Input: 'Help me find papers about attention mechanisms in transformers, focusing on efficient variants published after 2023' +Output: +GOAL: Find papers about efficient attention mechanisms in transformers published after 2023 +CONSTRAINTS: +- Focus on efficient variants (linear attention, sparse attention, etc.) +- Published after 2023 + +Input: 'Summarize my notes on distributed systems' +Output: +GOAL: Summarize the user's notes on distributed systems +CONSTRAINTS: none + +Now extract from the actual conversation. Output ONLY the GOAL and CONSTRAINTS lines."; + const MEMORY_HEADER: &str = "# User Model"; fn replace_or_insert_memory_message(messages: &mut Vec, content: &str) { @@ -871,6 +1037,26 @@ fn replace_or_insert_memory_message(messages: &mut Vec, content: } } +fn replace_or_insert_task_context(messages: &mut Vec, content: &str) { + if let Some(msg) = messages + .iter_mut() + .find(|m| m.role == "system" && m.content.starts_with(TASK_CONTEXT_HEADER)) + { + msg.content = content.to_string(); + } else { + let pos = messages + .iter() + .position(|m| m.role == "system" && m.content.starts_with(MEMORY_HEADER)) + .map(|i| i + 1) + .unwrap_or_else(|| if messages.is_empty() { 0 } else { 1 }); + messages.insert(pos, LlmChatMessage { + role: "system".to_string(), + content: content.to_string(), + ..Default::default() + }); + } +} + #[cfg(test)] mod error_format_tests { use super::*; @@ -1066,3 +1252,258 @@ mod loop_detector_tests { assert!(!ld.check("t", &args)); } } + +#[cfg(test)] +mod retry_tests { + use super::*; + use crate::tools::types::{ToolError, ToolErrorCode, ToolMetrics, ToolResult as TR}; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn ok_result() -> TR { + TR::Ok { + data: json!("ok"), + redacted_count: 0, + warnings: vec![], + metrics: ToolMetrics::default(), + } + } + + fn retryable_err() -> TR { + TR::Err { + error: ToolError { + code: ToolErrorCode::Timeout, + message: "timed out".to_string(), + retryable: true, + cause: None, + }, + } + } + + fn non_retryable_err() -> TR { + TR::Err { + error: ToolError { + code: ToolErrorCode::NotFound, + message: "not found".to_string(), + retryable: false, + cause: None, + }, + } + } + + #[tokio::test] + async fn retries_until_success() { + let cancel = CancellationToken::new(); + let call_count = Arc::new(AtomicU32::new(0)); + let cc = call_count.clone(); + + let result = retry_with_backoff(retryable_err(), &cancel, || { + let cc = cc.clone(); + async move { + let n = cc.fetch_add(1, Ordering::SeqCst); + if n < 1 { + retryable_err() + } else { + ok_result() + } + } + }) + .await; + + assert!(matches!(result, TR::Ok { .. })); + assert_eq!(call_count.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn cancel_stops_retry() { + let cancel = CancellationToken::new(); + let call_count = Arc::new(AtomicU32::new(0)); + let cc = call_count.clone(); + + cancel.cancel(); + + let result = retry_with_backoff(retryable_err(), &cancel, || { + let cc = cc.clone(); + async move { + cc.fetch_add(1, Ordering::SeqCst); + retryable_err() + } + }) + .await; + + assert!(matches!(result, TR::Err { .. })); + assert_eq!(call_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn non_retryable_skips_retry() { + let cancel = CancellationToken::new(); + let call_count = Arc::new(AtomicU32::new(0)); + let cc = call_count.clone(); + + let result = retry_with_backoff(non_retryable_err(), &cancel, || { + let cc = cc.clone(); + async move { + cc.fetch_add(1, Ordering::SeqCst); + ok_result() + } + }) + .await; + + assert!(matches!(result, TR::Err { .. })); + assert_eq!(call_count.load(Ordering::SeqCst), 0); + } +} + +#[cfg(test)] +mod goal_extract_tests { + use super::*; + + fn sys(content: &str) -> LlmChatMessage { + LlmChatMessage { role: "system".to_string(), content: content.to_string(), ..Default::default() } + } + fn user(content: &str) -> LlmChatMessage { + LlmChatMessage { role: "user".to_string(), content: content.to_string(), ..Default::default() } + } + fn assistant(content: &str) -> LlmChatMessage { + LlmChatMessage { role: "assistant".to_string(), content: content.to_string(), ..Default::default() } + } + + #[test] + fn insert_after_memory() { + let mut msgs = vec![ + sys("core system prompt"), + sys("# User Model\nsome memory"), + user("hello"), + ]; + replace_or_insert_task_context(&mut msgs, "# Task Context\nGOAL: test"); + assert_eq!(msgs.len(), 4); + assert!(msgs[2].content.starts_with(TASK_CONTEXT_HEADER)); + assert!(msgs[1].content.starts_with(MEMORY_HEADER)); + } + + #[test] + fn insert_no_memory() { + let mut msgs = vec![ + sys("core system prompt"), + user("hello"), + ]; + replace_or_insert_task_context(&mut msgs, "# Task Context\nGOAL: test"); + assert_eq!(msgs.len(), 3); + assert!(msgs[1].content.starts_with(TASK_CONTEXT_HEADER)); + } + + #[test] + fn update_existing() { + let mut msgs = vec![ + sys("core system prompt"), + sys("# User Model\nsome memory"), + sys("# Task Context\nGOAL: old goal"), + user("hello"), + ]; + replace_or_insert_task_context(&mut msgs, "# Task Context\nGOAL: new goal"); + assert_eq!(msgs.len(), 4); + assert_eq!(msgs[2].content, "# Task Context\nGOAL: new goal"); + } + + #[test] + fn insert_empty_messages() { + let mut msgs: Vec = vec![]; + replace_or_insert_task_context(&mut msgs, "# Task Context\nGOAL: test"); + assert_eq!(msgs.len(), 1); + assert!(msgs[0].content.starts_with(TASK_CONTEXT_HEADER)); + } + + #[test] + fn extract_filters_empty_assistant() { + let msgs = vec![ + sys("system"), + user("Find papers about RAG"), + assistant(""), + LlmChatMessage { role: "tool".to_string(), content: "results...".to_string(), ..Default::default() }, + ]; + let conversation: String = msgs + .iter() + .rev() + .filter(|m| (m.role == "user" || m.role == "assistant") && !m.content.is_empty()) + .take(6) + .collect::>() + .into_iter() + .rev() + .map(|m| format!("[{}]: {}", m.role, &m.content)) + .collect::>() + .join("\n"); + assert_eq!(conversation, "[user]: Find papers about RAG"); + assert!(!conversation.contains("[assistant]:")); + } + + #[test] + fn loop_warning_includes_goal_when_present() { + let mut msgs = vec![ + sys("core system prompt"), + sys("# Task Context\nGOAL: Find papers about RAG\nCONSTRAINTS: none"), + user("hello"), + ]; + let any_looped = true; + if any_looped { + let mut warning = String::from( + "WARNING: One or more tool calls were skipped because the same tool \ + was called repeatedly with identical arguments. Vary your approach \ + or use a different tool." + ); + if let Some(goal_msg) = msgs.iter().find( + |m| m.role == "system" && m.content.starts_with(TASK_CONTEXT_HEADER) + ) { + let goal_body = goal_msg.content + .strip_prefix(TASK_CONTEXT_HEADER) + .unwrap_or(&goal_msg.content) + .trim(); + if !goal_body.is_empty() { + warning.push_str("\n\nReminder — your original task:\n"); + warning.push_str(goal_body); + } + } + msgs.push(LlmChatMessage { + role: "system".to_string(), + content: warning, + ..Default::default() + }); + } + let warning_msg = msgs.last().unwrap(); + assert!(warning_msg.content.contains("Reminder — your original task:")); + assert!(warning_msg.content.contains("GOAL: Find papers about RAG")); + } + + #[test] + fn loop_warning_no_goal_no_reminder() { + let mut msgs = vec![ + sys("core system prompt"), + user("hello"), + ]; + let any_looped = true; + if any_looped { + let mut warning = String::from( + "WARNING: One or more tool calls were skipped." + ); + if let Some(goal_msg) = msgs.iter().find( + |m| m.role == "system" && m.content.starts_with(TASK_CONTEXT_HEADER) + ) { + let goal_body = goal_msg.content + .strip_prefix(TASK_CONTEXT_HEADER) + .unwrap_or(&goal_msg.content) + .trim(); + if !goal_body.is_empty() { + warning.push_str("\n\nReminder — your original task:\n"); + warning.push_str(goal_body); + } + } + msgs.push(LlmChatMessage { + role: "system".to_string(), + content: warning, + ..Default::default() + }); + } + let warning_msg = msgs.last().unwrap(); + assert!(!warning_msg.content.contains("Reminder")); + assert_eq!(warning_msg.content, "WARNING: One or more tool calls were skipped."); + } +} diff --git a/src-tauri/src/llm/context_guard.rs b/src-tauri/src/llm/context_guard.rs index 454e9a7..5db4957 100644 --- a/src-tauri/src/llm/context_guard.rs +++ b/src-tauri/src/llm/context_guard.rs @@ -9,7 +9,7 @@ const MIN_KEEP_ROUNDS: usize = 2; const MIN_MESSAGES_FOR_SUMMARY: usize = 4; const MAX_SUMMARY_INPUT_CHARS: usize = 6000; -const MAX_CONTENT_PER_MESSAGE: usize = 500; +const MAX_CONTENT_PER_MESSAGE: usize = 1000; const SUMMARY_SYSTEM: &str = "\ Summarize the following conversation into a structured summary.\n\ @@ -20,13 +20,15 @@ You MUST preserve:\n\ - Decisions made so far\n\ - Open questions or unresolved issues\n\ - What should happen next\n\n\ +If a previous summary is provided, merge its information into the new summary — \ +keep still-relevant items, drop outdated ones, and add new discoveries.\n\n\ Format:\n\ [Goal] ...\n\ [Findings] ...\n\ [Decisions] ...\n\ [Open] ...\n\ [Next] ...\n\n\ -Be concise. Each section 1-2 sentences max. Output only the summary."; +Be concise. Each section 1-3 sentences max. Output only the summary."; #[derive(Clone)] pub struct ContextGuard { @@ -118,7 +120,8 @@ impl ContextGuard { let removable_msgs: Vec<&LlmChatMessage> = removable_indices.iter().map(|&i| &messages[i]).collect(); - let summary_input = build_summary_input(&removable_msgs); + let previous_summary = find_previous_summary(messages); + let summary_input = build_summary_input(&removable_msgs, previous_summary.as_deref()); let overrides = CompletionOverrides { temperature: Some(0.0), ..Default::default() @@ -286,7 +289,8 @@ impl ContextGuard { let removable_msgs: Vec<&LlmChatMessage> = removable_indices.iter().map(|&i| &messages[i]).collect(); - let summary_input = build_summary_input(&removable_msgs); + let previous_summary = find_previous_summary(messages); + let summary_input = build_summary_input(&removable_msgs, previous_summary.as_deref()); let overrides = CompletionOverrides { temperature: Some(0.0), @@ -359,9 +363,29 @@ impl ContextGuard { } } -fn build_summary_input(messages: &[&LlmChatMessage]) -> Vec { +const EARLIER_SUMMARY_PREFIX: &str = "[Earlier conversation summary]"; + +fn find_previous_summary(messages: &[LlmChatMessage]) -> Option { + messages + .iter() + .filter(|m| m.role == "system" && m.content.starts_with(EARLIER_SUMMARY_PREFIX)) + .last() + .map(|m| m.content[EARLIER_SUMMARY_PREFIX.len()..].trim().to_string()) +} + +fn build_summary_input( + messages: &[&LlmChatMessage], + previous_summary: Option<&str>, +) -> Vec { let mut content = String::new(); - let mut char_count = 0; + + if let Some(prev) = previous_summary { + content.push_str("[Previous summary]:\n"); + content.push_str(prev); + content.push_str("\n\n[New messages]:\n"); + } + + let mut char_count = content.len(); for m in messages { let truncated = truncate_for_summary(&m.content, MAX_CONTENT_PER_MESSAGE); @@ -690,13 +714,13 @@ mod tests { #[test] fn build_summary_input_truncates_long_messages() { - let long_msg = user(&"x".repeat(1000)); + let long_msg = user(&"x".repeat(2000)); let msgs: Vec<&LlmChatMessage> = vec![&long_msg]; - let result = build_summary_input(&msgs); + let result = build_summary_input(&msgs, None); assert_eq!(result.len(), 2); assert_eq!(result[0].role, "system"); assert!(result[0].content.contains("Summarize")); - assert!(result[1].content.len() < 1000); + assert!(result[1].content.len() < 2000); } #[test] @@ -705,10 +729,42 @@ mod tests { .map(|i| user(&format!("message {}: {}", i, "x".repeat(400)))) .collect(); let refs: Vec<&LlmChatMessage> = big_msgs.iter().collect(); - let result = build_summary_input(&refs); + let result = build_summary_input(&refs, None); assert!(result[1].content.len() <= MAX_SUMMARY_INPUT_CHARS + MAX_CONTENT_PER_MESSAGE + 50); } + #[test] + fn build_summary_input_includes_previous_summary() { + let msg = user("new message"); + let msgs: Vec<&LlmChatMessage> = vec![&msg]; + let result = build_summary_input(&msgs, Some("User wanted to find bugs.")); + let user_content = &result[1].content; + assert!(user_content.contains("[Previous summary]:")); + assert!(user_content.contains("User wanted to find bugs.")); + assert!(user_content.contains("[New messages]:")); + assert!(user_content.contains("new message")); + } + + #[test] + fn find_previous_summary_extracts_content() { + let msgs = vec![ + sys("core prompt"), + sys(&format!("{}\n[Goal] Fix auth bug\n[Findings] Token expired", EARLIER_SUMMARY_PREFIX)), + user("next question"), + ]; + let prev = find_previous_summary(&msgs); + assert!(prev.is_some()); + let text = prev.unwrap(); + assert!(text.contains("[Goal] Fix auth bug")); + assert!(text.contains("[Findings] Token expired")); + } + + #[test] + fn find_previous_summary_returns_none_without_summary() { + let msgs = vec![sys("core prompt"), user("question")]; + assert!(find_previous_summary(&msgs).is_none()); + } + #[test] fn truncate_for_summary_respects_boundaries() { let s = "你好世界"; // 12 bytes total diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index 40d124f..2fd4d27 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -10,7 +10,7 @@ pub(crate) mod tool_result_processor; pub mod memory; pub use provider::{ - build_shared_http_client, create_provider, create_provider_by_id, CompletionOverrides, + build_shared_http_client, create_provider, CompletionOverrides, LlmProvider, }; @@ -797,7 +797,6 @@ pub async fn list_models( ai.parameters.top_p, ai.request.timeout_ms, profile.and_then(|p| p.organization_id.clone()), - profile.map(|p| p.is_remote).unwrap_or(true), ); provider.list_models().await } diff --git a/src-tauri/src/llm/provider.rs b/src-tauri/src/llm/provider.rs index c306422..8ad069e 100644 --- a/src-tauri/src/llm/provider.rs +++ b/src-tauri/src/llm/provider.rs @@ -60,8 +60,6 @@ pub trait LlmProvider: Send + Sync { #[allow(dead_code)] fn provider_name(&self) -> &'static str; - fn is_remote(&self) -> bool; - /// Return the model's context window size in tokens, if known. /// Used by ContextGuard as fallback when user doesn't configure max_context_tokens. fn model_context_window(&self) -> Option { @@ -107,21 +105,6 @@ pub fn create_provider( create_provider_from_profile(profile, config, model_override, http_client) } -/// Create a provider for a specific profile identified by `provider_id`. -pub fn create_provider_by_id( - config: &AiConfig, - provider_id: &str, - model_override: Option<&str>, - http_client: &Arc, -) -> Result, String> { - let profile = config - .providers - .iter() - .find(|p| p.id == provider_id) - .ok_or_else(|| format!("Provider '{}' not found in config.", provider_id))?; - create_provider_from_profile(profile, config, model_override, http_client) -} - fn create_provider_from_profile( profile: &ProviderProfile, config: &AiConfig, @@ -145,7 +128,6 @@ fn create_provider_from_profile( config.parameters.top_p, config.request.timeout_ms, profile.organization_id.clone(), - profile.is_remote, ), )) } diff --git a/src-tauri/src/llm/provider_impl.rs b/src-tauri/src/llm/provider_impl.rs index ce35d87..37dabf7 100644 --- a/src-tauri/src/llm/provider_impl.rs +++ b/src-tauri/src/llm/provider_impl.rs @@ -19,7 +19,6 @@ pub struct UnifiedProvider { top_p: Option, timeout_ms: u64, organization_id: Option, - is_remote: bool, } // OpenAI API enforces ^[a-zA-Z0-9_-]+$ for function names — dots are not @@ -46,7 +45,6 @@ impl UnifiedProvider { top_p: Option, timeout_ms: u64, organization_id: Option, - is_remote: bool, ) -> Self { Self { client, @@ -57,7 +55,6 @@ impl UnifiedProvider { top_p, timeout_ms, organization_id, - is_remote, } } @@ -609,10 +606,6 @@ impl LlmProvider for UnifiedProvider { "openai-compatible" } - fn is_remote(&self) -> bool { - self.is_remote - } - fn model_context_window(&self) -> Option { infer_context_window(&self.model) } @@ -687,7 +680,6 @@ mod tests { None, 30000, None, - true, ); let msg = provider.build_tool_result_message("call_123", "web.search", "some result"); assert_eq!(msg.role, "tool"); @@ -779,7 +771,6 @@ mod tests { None, 30000, None, - true, ); let manifests = vec![json!({ "name": "web.search", diff --git a/src-tauri/src/llm/tool_result_processor.rs b/src-tauri/src/llm/tool_result_processor.rs index 9d0972d..0d0341a 100644 --- a/src-tauri/src/llm/tool_result_processor.rs +++ b/src-tauri/src/llm/tool_result_processor.rs @@ -325,7 +325,7 @@ pub fn extract_user_goal(messages: &[LlmChatMessage]) -> Option { .map(|m| truncate_at_boundary(&m.content, MAX_GOAL_CHARS).to_string()) } -fn truncate_at_boundary(s: &str, max: usize) -> &str { +pub(crate) fn truncate_at_boundary(s: &str, max: usize) -> &str { if s.len() <= max { return s; } @@ -551,9 +551,5 @@ mod tests { fn provider_name(&self) -> &'static str { "fake" } - - fn is_remote(&self) -> bool { - false - } } } diff --git a/src-tauri/src/skills/mod.rs b/src-tauri/src/skills/mod.rs index 9ab347a..3d326bb 100644 --- a/src-tauri/src/skills/mod.rs +++ b/src-tauri/src/skills/mod.rs @@ -145,8 +145,9 @@ fn challenge_review_manifest() -> SkillManifest { "note.read".to_string(), "note.list".to_string(), "thought.list".to_string(), + "thought.read".to_string(), ], - max_tool_calls: 6, + max_tool_calls: 8, timeout_secs: 45, ui_entry: SkillUiEntry::ConversationMode, tags: vec!["review".to_string(), "coach".to_string()], diff --git a/src-tauri/src/tools/built_in/graph_ops.rs b/src-tauri/src/tools/built_in/graph_ops.rs index 69343b2..6d419d9 100644 --- a/src-tauri/src/tools/built_in/graph_ops.rs +++ b/src-tauri/src/tools/built_in/graph_ops.rs @@ -1,6 +1,11 @@ +use std::collections::HashSet; +use std::path::Path; + use async_trait::async_trait; use serde_json::Value; +use crate::topic_network::TopicNetworkForUi; + use crate::tools::context::ToolContext; use crate::tools::types::{ ApprovalPolicy, Effect, Risk, Tool, ToolCategory, ToolError, ToolErrorCode, ToolManifest, @@ -63,14 +68,15 @@ impl Tool for GraphQueryTopicNetworkTool { async fn invoke(&self, ctx: &ToolContext, _input: Value) -> ToolResult { let start = std::time::Instant::now(); let root = ctx.workspace_root.clone(); + let root_for_blocking = root.clone(); let result = tauri::async_runtime::spawn_blocking(move || { - let conn = crate::topic_network::open_topic_db(&root)?; - crate::topic_network::load_topic_network_graph(&root, &conn) + let conn = crate::topic_network::open_topic_db(&root_for_blocking)?; + crate::topic_network::load_topic_network_graph(&root_for_blocking, &conn) }) .await; - let network = match result { + let mut network = match result { Ok(Ok(n)) => n, Ok(Err(e)) => { return ToolResult::Err { @@ -94,12 +100,14 @@ impl Tool for GraphQueryTopicNetworkTool { } }; + let redacted = filter_private_nodes(&mut network, &root); + let duration_ms = start.elapsed().as_millis() as u64; let data = serde_json::to_value(&network).unwrap_or(serde_json::json!({})); ToolResult::Ok { data, - redacted_count: 0, + redacted_count: redacted as u32, warnings: vec![], metrics: ToolMetrics { duration_ms, @@ -240,3 +248,125 @@ impl Tool for IndexStatusTool { } } } + +// ─── Privacy filtering ────────────────────────────────────────────────────── + +fn filter_private_nodes(network: &mut TopicNetworkForUi, workspace_root: &Path) -> u32 { + let private_paths: HashSet = network + .doc_nodes + .iter() + .filter(|n| { + let full = workspace_root.join(&n.rel_path); + crate::note_privacy::peek_kf_private_from_md_file(&full) + }) + .map(|n| n.rel_path.clone()) + .collect(); + + let redacted = private_paths.len() as u32; + network + .doc_nodes + .retain(|n| !private_paths.contains(&n.rel_path)); + network + .topic_doc_edges + .retain(|e| !private_paths.contains(&e.doc_rel_path)); + redacted +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::topic_network::{DocNode, TopicDocEdge, TopicNetworkMeta, TopicNode}; + use std::io::Write; + + fn make_network(doc_paths: &[&str], edges: &[(&str, &str)]) -> TopicNetworkForUi { + TopicNetworkForUi { + topic_nodes: vec![TopicNode { + id: "t1".into(), + name: "rust".into(), + doc_count: doc_paths.len(), + related_topic_count: 0, + }], + doc_nodes: doc_paths + .iter() + .map(|p| DocNode { + rel_path: p.to_string(), + topic_count: 1, + thought_count: 0, + max_maturity: "seed".into(), + }) + .collect(), + topic_doc_edges: edges + .iter() + .map(|(tid, dp)| TopicDocEdge { + topic_id: tid.to_string(), + doc_rel_path: dp.to_string(), + }) + .collect(), + topic_topic_edges: vec![], + meta: TopicNetworkMeta { + topic_node_cap: 50, + doc_node_cap: 50, + truncated_topic_count: 0, + truncated_doc_count: 0, + extract_skipped_no_llm: false, + }, + } + } + + #[test] + fn private_doc_nodes_and_edges_filtered() { + let dir = tempfile::tempdir().unwrap(); + let private_path = dir.path().join("secret.md"); + let mut f = std::fs::File::create(&private_path).unwrap(); + writeln!(f, "---\nkf-private: true\n---\nSecret content").unwrap(); + + let public_path = dir.path().join("public.md"); + let mut f2 = std::fs::File::create(&public_path).unwrap(); + writeln!(f2, "---\ntitle: Public\n---\nPublic content").unwrap(); + + let mut network = make_network( + &["secret.md", "public.md"], + &[("t1", "secret.md"), ("t1", "public.md")], + ); + + let redacted = filter_private_nodes(&mut network, dir.path()); + + assert_eq!(redacted, 1); + assert_eq!(network.doc_nodes.len(), 1); + assert_eq!(network.doc_nodes[0].rel_path, "public.md"); + assert_eq!(network.topic_doc_edges.len(), 1); + assert_eq!(network.topic_doc_edges[0].doc_rel_path, "public.md"); + } + + #[test] + fn no_private_docs_returns_zero_redacted() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("note.md"); + let mut f = std::fs::File::create(&p).unwrap(); + writeln!(f, "---\ntitle: Note\n---\nContent").unwrap(); + + let mut network = make_network(&["note.md"], &[("t1", "note.md")]); + + let redacted = filter_private_nodes(&mut network, dir.path()); + + assert_eq!(redacted, 0); + assert_eq!(network.doc_nodes.len(), 1); + assert_eq!(network.topic_doc_edges.len(), 1); + } + + #[test] + fn topic_nodes_preserved_when_doc_filtered() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("secret.md"); + let mut f = std::fs::File::create(&p).unwrap(); + writeln!(f, "---\nkf-private: true\n---\nSecret").unwrap(); + + let mut network = make_network(&["secret.md"], &[("t1", "secret.md")]); + + filter_private_nodes(&mut network, dir.path()); + + assert!(network.doc_nodes.is_empty()); + assert!(network.topic_doc_edges.is_empty()); + assert_eq!(network.topic_nodes.len(), 1, "topic node must not be removed"); + } +} diff --git a/src-tauri/src/tools/built_in/link_ops.rs b/src-tauri/src/tools/built_in/link_ops.rs index 37ee9a6..49e61d2 100644 --- a/src-tauri/src/tools/built_in/link_ops.rs +++ b/src-tauri/src/tools/built_in/link_ops.rs @@ -76,18 +76,6 @@ impl Tool for LinkSuggestRelatedTool { } }; - // 路径安全性校验 - if let Err(e) = crate::note_privacy::validate_workspace_rel_path(&rel_path) { - return ToolResult::Err { - error: ToolError { - code: ToolErrorCode::InvalidInput, - message: e, - retryable: false, - cause: None, - }, - }; - } - let max_results = input .get("max_results") .and_then(|v| v.as_u64()) @@ -100,11 +88,17 @@ impl Tool for LinkSuggestRelatedTool { let result = tauri::async_runtime::spawn_blocking( move || -> Result, String> { - let full_path = root.join(&rel); - - if !full_path.exists() { - return Err("__NOT_FOUND__".to_string()); - } + let full_path = + crate::tools::path_safety::resolve_existing_under_root(&root, &rel).map_err( + |e| { + use crate::tools::path_safety::PathSafetyError::*; + match &e { + NotFound(_) => "__NOT_FOUND__".to_string(), + OutsideWorkspace => "__OUTSIDE_WORKSPACE__".to_string(), + _ => e.to_string(), + } + }, + )?; if crate::note_privacy::peek_kf_private_from_md_file(&full_path) { return Err("__PRIVACY_BLOCKED__".to_string()); @@ -135,7 +129,7 @@ impl Tool for LinkSuggestRelatedTool { let recommendations = match result { Ok(Ok(r)) => r, - Ok(Err(e)) if e == "__NOT_FOUND__" => { + Ok(Err(e)) if e == "__NOT_FOUND__" || e == "__OUTSIDE_WORKSPACE__" => { return ToolResult::Err { error: ToolError { code: ToolErrorCode::NotFound, diff --git a/src-tauri/src/tools/built_in/note_ops.rs b/src-tauri/src/tools/built_in/note_ops.rs index 23eefac..fc4e2c0 100644 --- a/src-tauri/src/tools/built_in/note_ops.rs +++ b/src-tauri/src/tools/built_in/note_ops.rs @@ -226,29 +226,22 @@ impl Tool for NoteReadTool { } }; - // 路径安全性校验 - if let Err(e) = crate::note_privacy::validate_workspace_rel_path(&rel_path) { - return ToolResult::Err { - error: ToolError { - code: ToolErrorCode::InvalidInput, - message: e, - retryable: false, - cause: None, - }, - }; - } - let root = ctx.workspace_root.clone(); let rel = rel_path.clone(); let result = tauri::async_runtime::spawn_blocking(move || -> Result<(String, usize), String> { - let full_path = root.join(&rel); - - // 确认文件存在 - if !full_path.exists() { - return Err("note not found".to_string()); - } + let full_path = + crate::tools::path_safety::resolve_existing_under_root(&root, &rel).map_err( + |e| { + use crate::tools::path_safety::PathSafetyError::*; + match &e { + NotFound(_) => "note not found".to_string(), + OutsideWorkspace => "__OUTSIDE_WORKSPACE__".to_string(), + _ => e.to_string(), + } + }, + )?; // 检查是否私密 if crate::note_privacy::peek_kf_private_from_md_file(&full_path) { @@ -274,7 +267,7 @@ impl Tool for NoteReadTool { }, } } - Ok(Err(e)) if e == "note not found" => { + Ok(Err(e)) if e == "__OUTSIDE_WORKSPACE__" || e == "note not found" => { return ToolResult::Err { error: ToolError { code: ToolErrorCode::NotFound, @@ -960,3 +953,35 @@ fn err_invalid_input(msg: &str) -> ToolResult { } } +#[cfg(test)] +mod tests { + use crate::tools::path_safety::{resolve_existing_under_root, PathSafetyError}; + + #[cfg(unix)] + #[test] + fn resolve_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + let root = tempfile::tempdir().unwrap(); + let root = root.path().canonicalize().unwrap(); + + let outside = tempfile::tempdir().unwrap(); + let secret = outside.path().join("secret.md"); + std::fs::write(&secret, "leak").unwrap(); + symlink(&secret, root.join("link.md")).unwrap(); + + let err = resolve_existing_under_root(&root, "link.md").unwrap_err(); + assert!(matches!(err, PathSafetyError::OutsideWorkspace)); + } + + #[test] + fn resolve_allows_normal_file() { + let root = tempfile::tempdir().unwrap(); + let root = root.path().canonicalize().unwrap(); + std::fs::write(root.join("note.md"), "hello").unwrap(); + + let resolved = resolve_existing_under_root(&root, "note.md").unwrap(); + assert!(resolved.starts_with(&root)); + assert!(resolved.ends_with("note.md")); + } +} + diff --git a/src-tauri/src/tools/built_in/thought_ops.rs b/src-tauri/src/tools/built_in/thought_ops.rs index 2133a41..abae02c 100644 --- a/src-tauri/src/tools/built_in/thought_ops.rs +++ b/src-tauri/src/tools/built_in/thought_ops.rs @@ -7,6 +7,7 @@ use crate::tools::types::{ ApprovalPolicy, Effect, Risk, Tool, ToolCategory, ToolError, ToolErrorCode, ToolManifest, ToolMetrics, ToolResult, }; +use crate::vault_thoughts_db; // ─── ThoughtListTool ─────────────────────────────────────────────────────────── @@ -282,3 +283,266 @@ impl Tool for ThoughtCreateTool { } } } + +// ─── ThoughtReadTool ───────────────────────────────────────────────────────── + +pub struct ThoughtReadTool { + manifest: ToolManifest, +} + +impl ThoughtReadTool { + pub fn new() -> Self { + Self { + manifest: ToolManifest { + name: "thought.read".to_string(), + version: "1.0.0".to_string(), + protocol_version: "1.0".to_string(), + description: "Read the full body and metadata of a single Thought by its ID. \ + Use thought.list first to discover thought IDs, then call this \ + tool to retrieve the complete content for deeper analysis, \ + challenge review, or linking." + .to_string(), + input_schema: serde_json::json!({ + "type": "object", + "required": ["thought_id"], + "properties": { + "thought_id": { + "type": "string", + "description": "The thought ID returned by thought.list" + } + }, + "additionalProperties": false + }), + output_schema: serde_json::json!({ + "type": "object", + "properties": { + "thought_id": { "type": "string" }, + "body": { "type": "string" }, + "summary": { "type": ["string", "null"] }, + "maturity": { "type": "string" }, + "temporary": { "type": "boolean" }, + "standalone": { "type": "boolean" }, + "note_rel_path": { "type": "string" }, + "created_at": { "type": "string" }, + "updated_at": { "type": "string" }, + "challenge_pass_count": { "type": "integer" }, + "last_reviewed_at": { "type": ["string", "null"] } + } + }), + effects: vec![Effect::Read], + risk: Risk::Safe, + privacy_aware: true, + requires_workspace: true, + default_approval: ApprovalPolicy::Auto, + examples: vec![], + tags: vec!["thought".to_string(), "read".to_string()], + deprecated: None, + }, + } + } +} + +#[async_trait] +impl Tool for ThoughtReadTool { + fn manifest(&self) -> &ToolManifest { + &self.manifest + } + + fn category(&self) -> ToolCategory { + ToolCategory::NoteRead + } + + async fn invoke(&self, ctx: &ToolContext, input: Value) -> ToolResult { + let start = std::time::Instant::now(); + + let thought_id = match input.get("thought_id").and_then(|v| v.as_str()) { + Some(id) if !id.trim().is_empty() => id.to_string(), + _ => { + return ToolResult::Err { + error: ToolError { + code: ToolErrorCode::InvalidInput, + message: "thought_id is required".to_string(), + retryable: false, + cause: None, + }, + } + } + }; + + let root = ctx.workspace_root.clone(); + let privacy_filter = Arc::clone(&ctx.privacy_filter); + let workspace_root_for_filter = root.clone(); + + let result = tauri::async_runtime::spawn_blocking(move || { + let conn = vault_thoughts_db::open_thoughts_db(&root)?; + vault_thoughts_db::get_thought_detail(&conn, &thought_id) + }) + .await; + + let detail = match result { + Ok(Ok(Some(d))) => d, + Ok(Ok(None)) => { + return ToolResult::Err { + error: ToolError { + code: ToolErrorCode::NotFound, + message: "thought not found".to_string(), + retryable: false, + cause: None, + }, + } + } + Ok(Err(e)) => { + return ToolResult::Err { + error: ToolError { + code: ToolErrorCode::Internal, + message: e, + retryable: true, + cause: None, + }, + } + } + Err(e) => { + return ToolResult::Err { + error: ToolError { + code: ToolErrorCode::Internal, + message: e.to_string(), + retryable: true, + cause: None, + }, + } + } + }; + + if should_block_private_thought(&detail, &*privacy_filter, &workspace_root_for_filter) { + return ToolResult::Err { + error: ToolError { + code: ToolErrorCode::PrivacyBlocked, + message: "this thought is linked to a private note".to_string(), + retryable: false, + cause: None, + }, + }; + } + + let duration_ms = start.elapsed().as_millis() as u64; + let data = serde_json::json!({ + "thought_id": detail.thought_id, + "body": detail.body, + "summary": detail.summary, + "maturity": detail.maturity, + "temporary": detail.temporary, + "standalone": detail.standalone, + "note_rel_path": detail.note_rel_path, + "created_at": detail.created_at, + "updated_at": detail.updated_at, + "challenge_pass_count": detail.challenge_pass_count, + "last_reviewed_at": detail.last_reviewed_at, + }); + + ToolResult::Ok { + data, + redacted_count: 0, + warnings: vec![], + metrics: ToolMetrics { + duration_ms, + ..Default::default() + }, + } + } +} + +fn should_block_private_thought( + detail: &vault_thoughts_db::ThoughtDetail, + privacy_filter: &dyn crate::tools::context::PrivacyFilter, + workspace_root: &std::path::Path, +) -> bool { + !detail.standalone + && !detail.note_rel_path.is_empty() + && privacy_filter.is_private_path(&detail.note_rel_path, workspace_root) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn blocks_thought_linked_to_private_note() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + + let private = root.join("secret.md"); + let mut f = std::fs::File::create(&private).unwrap(); + writeln!(f, "---\nkf-private: true\n---\nSecret content").unwrap(); + + let detail = vault_thoughts_db::ThoughtDetail { + thought_id: "t-001".into(), + note_stable_id: "s-001".into(), + note_rel_path: "secret.md".into(), + body: "some private idea".into(), + summary: None, + maturity: "seedling".into(), + temporary: false, + standalone: false, + created_at: "2026-06-01T00:00:00Z".into(), + updated_at: "2026-06-01T00:00:00Z".into(), + challenge_pass_count: 0, + last_reviewed_at: None, + }; + + let filter = crate::tools::privacy::KfPrivateFilter; + assert!(should_block_private_thought(&detail, &filter, &root)); + } + + #[test] + fn allows_thought_linked_to_public_note() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + + let public = root.join("public.md"); + let mut f = std::fs::File::create(&public).unwrap(); + writeln!(f, "---\ntitle: Public\n---\nPublic content").unwrap(); + + let detail = vault_thoughts_db::ThoughtDetail { + thought_id: "t-002".into(), + note_stable_id: "s-002".into(), + note_rel_path: "public.md".into(), + body: "a public thought".into(), + summary: Some("public".into()), + maturity: "budding".into(), + temporary: false, + standalone: false, + created_at: "2026-06-01T00:00:00Z".into(), + updated_at: "2026-06-01T00:00:00Z".into(), + challenge_pass_count: 1, + last_reviewed_at: Some("2026-06-01T00:00:00Z".into()), + }; + + let filter = crate::tools::privacy::KfPrivateFilter; + assert!(!should_block_private_thought(&detail, &filter, &root)); + } + + #[test] + fn allows_standalone_thought_regardless() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + + let detail = vault_thoughts_db::ThoughtDetail { + thought_id: "t-003".into(), + note_stable_id: "t-003".into(), + note_rel_path: ".knowforge/standalone/t-003".into(), + body: "standalone idea".into(), + summary: None, + maturity: "seedling".into(), + temporary: false, + standalone: true, + created_at: "2026-06-01T00:00:00Z".into(), + updated_at: "2026-06-01T00:00:00Z".into(), + challenge_pass_count: 0, + last_reviewed_at: None, + }; + + let filter = crate::tools::privacy::KfPrivateFilter; + assert!(!should_block_private_thought(&detail, &filter, &root)); + } +} diff --git a/src-tauri/src/tools/mod.rs b/src-tauri/src/tools/mod.rs index eca5160..ae2995d 100644 --- a/src-tauri/src/tools/mod.rs +++ b/src-tauri/src/tools/mod.rs @@ -95,6 +95,7 @@ pub fn register_builtin_tools( registry.register(Arc::new(built_in::note_ops::NoteListTool::new()))?; registry.register(Arc::new(built_in::note_ops::NoteReadTool::new()))?; registry.register(Arc::new(built_in::thought_ops::ThoughtListTool::new()))?; + registry.register(Arc::new(built_in::thought_ops::ThoughtReadTool::new()))?; registry.register(Arc::new(built_in::link_ops::LinkSuggestRelatedTool::new()))?; registry.register(Arc::new(built_in::graph_ops::GraphQueryTopicNetworkTool::new()))?; registry.register(Arc::new(built_in::graph_ops::IndexStatusTool::new()))?; @@ -139,9 +140,9 @@ mod mod_tests { "register_builtin_tools failed: {:?}", result.err() ); - // 确认工具总数:1(time.now) + 8(P1) + 4(P3 写操作) + 2(memory) + 4(P4 网络) + 1(recall) = 20 + // 确认工具总数:1(time.now) + 9(P1) + 4(P3 写操作) + 2(memory) + 4(P4 网络) + 1(recall) = 21 let tools = registry.list_for_llm(crate::tools::registry::ToolScope::Global); - assert_eq!(tools.len(), 20, "expected 20 registered tools, got {}", tools.len()); + assert_eq!(tools.len(), 21, "expected 21 registered tools, got {}", tools.len()); } #[test] @@ -151,8 +152,8 @@ mod mod_tests { let all = registry.list_for_llm(crate::tools::registry::ToolScope::Global); let core = registry.list_for_llm_filtered(&crate::tools::registry::ToolFilter::core()); assert!(core.len() < all.len(), "core ({}) should be less than all ({})", core.len(), all.len()); - // NoteRead(5) + Utility(1 time.now + 2 memory + 1 recall) = 9 - assert_eq!(core.len(), 9, "core should have 9 tools (5 NoteRead + 4 Utility)"); + // NoteRead(6) + Utility(1 time.now + 2 memory + 1 recall) = 10 + assert_eq!(core.len(), 10, "core should have 10 tools (6 NoteRead + 4 Utility)"); } #[test] @@ -170,6 +171,7 @@ mod mod_tests { check("vault.search_keyword", ToolCategory::NoteRead); check("vault.semantic_search", ToolCategory::NoteRead); check("thought.list", ToolCategory::NoteRead); + check("thought.read", ToolCategory::NoteRead); check("note.write_section", ToolCategory::NoteWrite); check("note.append", ToolCategory::NoteWrite); diff --git a/src-tauri/src/vault_thoughts_db.rs b/src-tauri/src/vault_thoughts_db.rs index d98b0f1..410c2ee 100644 --- a/src-tauri/src/vault_thoughts_db.rs +++ b/src-tauri/src/vault_thoughts_db.rs @@ -568,6 +568,52 @@ mod tests { use std::fs; use tempfile::tempdir; + #[test] + fn get_thought_detail_returns_full_metadata() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let conn = open_thoughts_db(root).unwrap(); + + upsert_thought_body( + &conn, + "t-001", + "note-abc", + "notes/rust.md", + "Ownership in Rust prevents data races at compile time.", + Some("Rust ownership"), + "budding", + false, + false, + "2026-06-01T10:00:00Z", + "2026-06-15T12:00:00Z", + 3, + Some("2026-06-14T09:00:00Z"), + ) + .unwrap(); + + let detail = get_thought_detail(&conn, "t-001").unwrap().unwrap(); + assert_eq!(detail.thought_id, "t-001"); + assert_eq!(detail.note_stable_id, "note-abc"); + assert_eq!(detail.note_rel_path, "notes/rust.md"); + assert_eq!(detail.body, "Ownership in Rust prevents data races at compile time."); + assert_eq!(detail.summary.as_deref(), Some("Rust ownership")); + assert_eq!(detail.maturity, "budding"); + assert!(!detail.temporary); + assert!(!detail.standalone); + assert_eq!(detail.created_at, "2026-06-01T10:00:00Z"); + assert_eq!(detail.updated_at, "2026-06-15T12:00:00Z"); + assert_eq!(detail.challenge_pass_count, 3); + assert_eq!(detail.last_reviewed_at.as_deref(), Some("2026-06-14T09:00:00Z")); + } + + #[test] + fn get_thought_detail_returns_none_for_missing() { + let dir = tempdir().unwrap(); + let root = dir.path(); + let conn = open_thoughts_db(root).unwrap(); + assert!(get_thought_detail(&conn, "nonexistent").unwrap().is_none()); + } + #[test] fn migrate_idempotent() { let dir = tempdir().unwrap(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 493bc5c..c9425e0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Knowforge", - "version": "0.7.2", + "version": "0.7.3", "identifier": "com.knowforge.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/AiConversationPanel.tsx b/src/components/AiConversationPanel.tsx index f6ddc70..aaf5792 100644 --- a/src/components/AiConversationPanel.tsx +++ b/src/components/AiConversationPanel.tsx @@ -304,6 +304,8 @@ export function AiConversationPanel() { const activeSessionRef = useRef(null); const listEndRef = useRef(null); + /** 是否吸附在底部:用户向上滚动查看历史时置 false,避免流式刷新强制拉回底部 */ + const stickToBottomRef = useRef(true); /** Iter 5 #4:工具调用总开关从 vault config 读取(默认 true,旧 vault 缺字段时取 true)。 * 通过 VAULT_CONFIG_UPDATED_EVENT 在设置保存后实时同步,无需重开会话。 */ @@ -497,6 +499,8 @@ export function AiConversationPanel() { setSelToolbar(null); setPrivacyChangeWarning(null); sharedDocPathsRef.current = new Set(); + // 切换会话时恢复底部吸附,新会话默认展示最新消息 + stickToBottomRef.current = true; }, [conversationId, setAutoResolved, setEnoughForThisChat]); /** 被动高亮门控横幅:展示 5 秒后自动收起(手动关闭见横幅按钮) */ @@ -563,14 +567,32 @@ export function AiConversationPanel() { return () => document.removeEventListener("selectionchange", onSelChange); }, []); - const scrollToBottom = useCallback(() => { + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + stickToBottomRef.current = true; requestAnimationFrame(() => { - listEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" }); + listEndRef.current?.scrollIntoView({ block: "end", behavior }); }); }, []); + /** 监听消息容器滚动:用户滚离底部超阈值即取消吸附,回到底部附近恢复吸附 */ useEffect(() => { - scrollToBottom(); + const container = messagesContainerRef.current; + if (!container) return; + const onScroll = () => { + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + stickToBottomRef.current = distanceFromBottom < 80; + }; + container.addEventListener("scroll", onScroll, { passive: true }); + return () => container.removeEventListener("scroll", onScroll); + }, []); + + useEffect(() => { + // 用户刚发出的消息始终滚到底部;否则仅在仍吸附底部时跟随,向上翻阅历史时不打断 + const last = messages[messages.length - 1]; + if (last?.role === "user" || stickToBottomRef.current) { + scrollToBottom(); + } }, [messages, scrollToBottom]); useEffect(() => {