diff --git a/Cargo.lock b/Cargo.lock index 21bd3db..f033e2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1823,6 +1823,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", + "tempfile", "tokio", "tracing", ] diff --git a/crates/neuromesh-api/src/server.rs b/crates/neuromesh-api/src/server.rs index a1231a4..a699e4b 100644 --- a/crates/neuromesh-api/src/server.rs +++ b/crates/neuromesh-api/src/server.rs @@ -72,13 +72,13 @@ impl HttpServer { ready_tx: Option>, ) -> Result<()> { let (listener, addr) = self.bind().await?; - println!("\n╔═══════════════════════════════════════════════════════════════════════════════════╗"); - println!( + eprintln!("\n╔═══════════════════════════════════════════════════════════════════════════════════╗"); + eprintln!( "║ 🌿 NEUROMESH v{} — UI MONITOR & MCP DASHBOARD ACTIVE ║", env!("CARGO_PKG_VERSION") ); - println!("║ Open in browser: \x1b[1;36mhttp://{}\x1b[0m ║", addr); - println!("╚═══════════════════════════════════════════════════════════════════════════════════╝\n"); + eprintln!("║ Open in browser: \x1b[1;36mhttp://{}\x1b[0m ║", addr); + eprintln!("╚═══════════════════════════════════════════════════════════════════════════════════╝\n"); if let Some(tx) = ready_tx { let _ = tx.send(addr.port()); } diff --git a/crates/neuromesh-cli/Cargo.toml b/crates/neuromesh-cli/Cargo.toml index db64d2c..951e413 100644 --- a/crates/neuromesh-cli/Cargo.toml +++ b/crates/neuromesh-cli/Cargo.toml @@ -43,3 +43,6 @@ embeddings = [ "neuromesh-context/embeddings", "neuromesh-mcp/embeddings", ] + +[dev-dependencies] +tempfile = "3.24" diff --git a/crates/neuromesh-cli/src/main.rs b/crates/neuromesh-cli/src/main.rs index af5bf8e..8bc2c14 100644 --- a/crates/neuromesh-cli/src/main.rs +++ b/crates/neuromesh-cli/src/main.rs @@ -10,16 +10,26 @@ use std::sync::Arc; /// Best-effort: open the system's default browser. Never fatal — the caller /// keeps working (stdio MCP, or the dashboard itself) if this fails. +/// `NEUROMESH_NO_BROWSER` turns it off (tests, headless machines). The child +/// never inherits stdout: in `mcp` mode that stream is the JSON-RPC channel. fn open_browser(url: &str) { - let result = if cfg!(target_os = "windows") { - std::process::Command::new("cmd") - .args(["/C", "start", "", url]) - .spawn() + if std::env::var_os("NEUROMESH_NO_BROWSER").is_some() { + return; + } + let mut cmd = if cfg!(target_os = "windows") { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "start", "", url]); + c } else if cfg!(target_os = "macos") { - std::process::Command::new("open").arg(url).spawn() + let mut c = std::process::Command::new("open"); + c.arg(url); + c } else { - std::process::Command::new("xdg-open").arg(url).spawn() + let mut c = std::process::Command::new("xdg-open"); + c.arg(url); + c }; + let result = cmd.stdout(std::process::Stdio::null()).spawn(); if let Err(e) = result { eprintln!("NeuroMesh: could not open browser automatically ({e}); open {url} manually."); } @@ -28,16 +38,20 @@ fn open_browser(url: &str) { /// The `mcp` process is spawned silently and often respawned by the MCP /// client (Claude Desktop, Cursor, …), so auto-opening a tab on every launch /// would spam the user. Open the dashboard automatically only the very first -/// time NeuroMesh ever starts on this machine; after that, `neuromesh monitor` +/// time NeuroMesh starts on *this project* (marker lives in that project's +/// own data dir, not a single machine-wide flag) — so a new project, or a +/// teammate cloning the same repo into their own `~/.neuromesh`, still gets +/// the one-time graphical first impression. After that, `neuromesh monitor` /// opens it on demand. -fn maybe_open_ui_first_run(port: u16) { - let marker = neuromesh_core::neuromesh_home().join(".ui_opened_once"); +fn maybe_open_ui_first_run(workspace: &std::path::Path, port: u16) { + let dir = match neuromesh_core::ensure_project_data_dir(workspace) { + Ok(dir) => dir, + Err(_) => return, + }; + let marker = dir.join(".ui_opened_once"); if marker.exists() { return; } - if std::fs::create_dir_all(neuromesh_core::neuromesh_home()).is_err() { - return; - } if std::fs::write(&marker, b"1").is_ok() { open_browser(&format!("http://127.0.0.1:{port}")); } @@ -302,9 +316,10 @@ async fn async_main(command: &str, args: &[String]) -> Result<()> { }); // Separate task: only open a browser once we know the real // bound port, but this never delays the stdio server below. + let workspace_for_ui = current_dir.clone(); tokio::spawn(async move { if let Ok(actual_port) = port_rx.await { - maybe_open_ui_first_run(actual_port); + maybe_open_ui_first_run(&workspace_for_ui, actual_port); } }); } diff --git a/crates/neuromesh-cli/tests/mcp_stdout_clean.rs b/crates/neuromesh-cli/tests/mcp_stdout_clean.rs new file mode 100644 index 0000000..aa15541 --- /dev/null +++ b/crates/neuromesh-cli/tests/mcp_stdout_clean.rs @@ -0,0 +1,87 @@ +//! `neuromesh mcp` speaks line-delimited JSON-RPC on stdout, and the same +//! process also starts the local dashboard. Anything else written to stdout +//! (a banner, a log line) breaks strict MCP clients that parse every line. +//! This spawns the real binary and checks that every stdout line is JSON. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +#[test] +fn mcp_stdout_carries_only_json_rpc() { + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + std::fs::write( + workspace.path().join("lib.rs"), + "pub fn add(a: i32, b: i32) -> i32 { a + b }\n", + ) + .unwrap(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_neuromesh")) + .arg("mcp") + .current_dir(workspace.path()) + .env("NEUROMESH_HOME", home.path()) + .env("NEUROMESH_NO_BROWSER", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn neuromesh mcp"); + + let stdout = child.stdout.take().unwrap(); + let (tx, rx) = mpsc::channel::(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + match line { + Ok(l) => { + if tx.send(l).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let mut stdin = child.stdin.take().unwrap(); + let init = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"stdout-test","version":"0"}}}"#; + let list = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#; + writeln!(stdin, "{init}").unwrap(); + writeln!(stdin, "{list}").unwrap(); + stdin.flush().unwrap(); + + let deadline = Instant::now() + Duration::from_secs(90); + let mut lines = Vec::new(); + let mut saw_list = false; + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(200)) { + Ok(line) => { + if line.contains("\"id\":2") { + saw_list = true; + } + lines.push(line); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + // Give the dashboard task time to bind and print after the replies. + if saw_list && lines.len() >= 2 { + std::thread::sleep(Duration::from_secs(2)); + while let Ok(line) = rx.try_recv() { + lines.push(line); + } + break; + } + } + let _ = child.kill(); + let _ = child.wait(); + + assert!(saw_list, "no tools/list reply on stdout; got {lines:?}"); + for line in lines.iter().filter(|l| !l.trim().is_empty()) { + assert!( + serde_json::from_str::(line).is_ok(), + "non-JSON line on MCP stdout: {line:?}" + ); + } +} diff --git a/docs/planning/11-roadmap-2026-09-30-concept-queries.fa.md b/docs/planning/11-roadmap-2026-09-30-concept-queries.fa.md new file mode 100644 index 0000000..87f72ef --- /dev/null +++ b/docs/planning/11-roadmap-2026-09-30-concept-queries.fa.md @@ -0,0 +1,29 @@ +# نقشه راه — بعد از گزارش یوسف (۲۰۲۶-۰۹-۳۰) + +ورودی: `handoff-2026-09-30-yoosef-findings.fa.md` — ۳ از ۷ روی ریپوی خودمان، ۲۶ ثانیه تاخیر روی یک query، و باگ stdout در حالت `mcp`. + +## تشخیص اولیه (قبل از هر اندازه‌گیری) + +ست `self` (۳۰ سوال) تقریباً همه‌اش اسم یک شناسه را در متن دارد (`resolve_cluster_noun_seeds`, `select()`, …). سوال‌های یوسف **مفهومی** هستند و هیچ شناسه‌ای ندارند: «ابزار چطور جلوی ایندکس کردن مسیرهای خطرناک مثل ریشه فایل‌سیستم را می‌گیرد؟». پس ست‌های ما این کلاس را اصلاً اندازه نمی‌گرفتند؛ عدد خوب روی `self` و عدد بد یوسف با هم تناقض ندارند. این کلاس (سوال مفهومی، بدون اسم) رایج‌ترین شکل سوال کاربر واقعی است، پس اولویت اول است. + +دومین مشکل: وقتی جواب غلط است، `coverage: no_recorded_gap` می‌گوید (اعتماد کاذب). عامل از روی این عدد تصمیم می‌گیرد جستجوی بیشتری بکند یا نه — اعتماد کاذب از جواب غلط بدتر است. + +## فازها + +| فاز | کار | معیار خروج | +|---|---|---| +| **A** — پروتکل | بنر داشبورد از stdout به stderr؛ تست یکپارچه: در حالت `mcp` هر خط stdout باید JSON معتبر باشد | تست سبز، روی باینری واقعی چک شده | +| **B** — ست مفهومی (قفل قبل از تیونینگ) | (۱) ست `concept-dev`: ۴ سوال یوسف + ~۱۲ سوال مفهومی دیگر روی همین ریپو، gold با خواندن کد. (۲) ست `concept-holdout`: ~۱۲ سوال مفهومی روی یک ریپوی **Rust شخص ثالث** (ripgrep، revision پین‌شده) که تا پایان فاز D اجرا نمی‌شود. gold هر دو قبل از دیدن هر packet قفل و commit می‌شود | دو فایل gold commit شده؛ baseline روی `concept-dev` اندازه‌گیری شده | +| **C** — تاخیر | بازتولید ۲۶ ثانیه با زمان‌گیری دقیق (باینری release، cache خاموش/روشن)؛ اگر واقعی بود پروفایل و رفع | p95 تک‌query روی ریپوی خودمان < ۲ ثانیه بعد از ایندکس | +| **D** — بازیابی مفهومی | ریشه‌یابی هر miss با `NM_EXPLAIN`؛ فقط قانون‌های عمومی (مثلاً: کلمات سوال ↔ doc-comment و نام فایل/ماژول، هم‌ریشه‌سازی `dangerous→safe/unsafe`, `cap→max/limit`)؛ کالیبره کردن `coverage` تا وقتی seed ضعیف است `partial`/`no_confident_match` بگوید | `concept-dev` ≥ ۰.۸۵ recall@packet، هیچ «no_recorded_gap» روی جواب غلط؛ ۹ ست قبلی بدون افت (ratchet −۰.۰۲) | +| **E** — اثبات روی holdout | یک بار اجرای `concept-holdout` بعد از قفل شدن کد | عدد صادقانه گزارش شود، چه خوب چه بد؛ اگر < ۰.۷ یافته‌ها ثبت و یک دور اصلاح عمومی دیگر | +| **F** — انتشار | v1.0.1: CHANGELOG، `docs/measured.md` (ستون جدید concept)، باینری‌ها از release.yml، جواب انگلیسی برای یوسف با اعداد و ground truth اصلاح‌شده `retry_negative` | tag + سه باینری، سند پاسخ | +| **G** — بک‌لاگ | ستون‌های token_precision/overhead (measure-only)، سپس GitHub Pages rebrand | در `measured.md` و Pages فعال | + +## قواعد اجرا + +- هر فاز یک branch و یک PR روی `ParsaVictor/code-context-engine`؛ merge بعد از CI سبز. +- فایل‌های تغییرداده‌شده توسط session دیگر (`README.md`, `crates/neuromesh-cli/src/main.rs`, `nm.config.json`) دست نمی‌خورند؛ فقط `git add ` با نام. +- gold قبل از packet قفل؛ هیچ قانونی که فقط یک سوال را درست کند (hardcode اسم) پذیرفته نیست. +- ratchetها فقط بالا می‌روند. +- `retry_negative` باگ نیست: این fork واقعاً retry دارد (`crates/neuromesh-provider/src/anthropic.rs`)؛ در ست ما gold آن همان فایل‌های provider است. diff --git a/docs/planning/handoff-2026-09-30-yoosef-findings.fa.md b/docs/planning/handoff-2026-09-30-yoosef-findings.fa.md new file mode 100644 index 0000000..07786b5 --- /dev/null +++ b/docs/planning/handoff-2026-09-30-yoosef-findings.fa.md @@ -0,0 +1,65 @@ +# Handoff — یافته‌های گزارش یوسف روی باینری v1.0.0 (self-repo)، ۲۰۲۶-۰۹-۳۰ + +منبع: یوسف (نویسنده اصلی NeuroMesh) گزارشی فرستاد که باینری v1.0.0 ما (Windows زیر Wine + Linux native) را روی همین ریپازیتوری خودمان (`neuromesh`/`code-context-engine`) با همون ۷-query battery قدیمی تست کرده. نتیجه گزارش‌شده: ۳/۷ (۴۳٪)، در مقابل upstream v0.9.11 که ۷/۷ گزارش شده. + +این session (Claude، با دسترسی زنده به MCP خود پروژه روی همین ریپو) سه‌تا از ۴ ادعای fail را **مستقل بازتولید و تایید کرد** با `get_context_packet` واقعی. یکی از ۴ ادعا **رد شد** (ground truth اشتباه بود). یک مورد (latency) تایید نشد چون زمان نکردیم. + +## تایید شده — باگ‌های واقعی (۳ مورد) + +### 1. `root_fs_safety` — پاسخ کاملاً اشتباه +Query: "How does this tool prevent indexing dangerous paths like the filesystem root?" +- پاسخ گرفته‌شده: `crates/neuromesh-mcp/src/descriptors.rs` (تعریف ابزارهای MCP — کاملاً بی‌ربط) +- پاسخ درست: `crates/neuromesh-index/src/confine.rs` — دارای `is_safe_workspace`, `assert_safe_workspace`, `path_escapes_workspace` +- `coverage: no_recorded_gap` یعنی سیستم حتی متوجه نشد که جواب اشتباه داده (اعتماد کاذب) +- packet_id: `ctx_58280a8891c44c0687c04d654633d642` + +### 2. `reinforcement` — پاسخ اشتباه +Query: "How does a file's importance get reinforced after repeated edits?" +- پاسخ گرفته‌شده: `crates/neuromesh-graph-proxy/src/cbm.rs` (کلاینت CBM proxy — بی‌ربط) +- پاسخ درست: احتمالاً `crates/neuromesh-graph/src/edge.rs` یا `crates/neuromesh-context/src/activator.rs` (`PheromoneEngine`/`reinforce_path` — طبق grep قبلی این‌جا هستن) +- `coverage: partial`, `sufficiency_score: 0.494` +- packet_id: `ctx_fd9a76b1574641a9acec12449bd76dc5` + +### 3. `max_files_cap` — پاسخ اشتباه +Query: "How does the system determine the maximum number of files to index automatically?" +- پاسخ گرفته‌شده: `crates/neuromesh-cli/src/commands/tasks.rs` (task harness — بی‌ربط) +- پاسخ درست: باید `FileCapArg` / `max_files_from_args` باشه (این نمادها طبق grep در `crates/neuromesh-cli/src/main.rs` و `commands/*.rs` پخش شدن — باید دقیق پیدا بشه) +- `coverage: partial`, `sufficiency_score: 0.477` +- packet_id: `ctx_866ce80ff3aa455ea18f5957827e9220` + +**نتیجه مشترک:** هر سه مورد seed resolution/retrieval روی این ریپوی خاص (Rust، چندماژوله، اسم‌های عمومی مثل `confine`, `reinforce`, `cap`) گم می‌شه و به‌جای فایل درست یه فایل نسبتاً بی‌ربط با امتیاز utility بالا برمی‌گرده. این دقیقاً همون کلاس مشکلی هست که در benchmarkهای holdout قبلی (stage 4/5) دیده و تا حدی حل شده بود، اما ظاهراً روی این ریپوی خاص (self) هنوز ضعیفه — که با عدد قبلی‌مون هم جور در میاد: session 15 گزارش کرده بود self(30) precision 0.867 ولی recall فقط 0.583 — یعنی حتی قبلاً هم می‌دونستیم این holdout ضعیف‌تره، فقط این ۳ query مشخص رو تست نکرده بودیم. + +## رد شده — ادعای نادرست (۱ مورد) + +### `retry_negative` — این یک باگ نیست +Query: "Is there retry logic or exponential backoff when calling the AI provider API?" +- گزارش یوسف انتظار داشت جواب `no_confident_match` باشه (چون در ریپوی upstream چنین منطقی وجود نداره) +- اما **این ریپو (فورک ما) واقعاً retry/backoff داره**: `crates/neuromesh-provider/src/anthropic.rs` یک حلقه retry کامل با `ANTHROPIC_MAX_RETRIES`, backoff نمایی (`15u64 << attempt`), و تشخیص خطای transient (429/5xx/"Budget pool"/"overloaded") داره. `openai.rs` هم مشابه. +- سیستم ما این فایل‌ها رو درست پیدا کرد و جواب داد (`coverage: partial` ولی با محتوای درست: انتخاب فایل‌های provider که واقعاً retry دارن) +- **نتیجه:** ground truth گزارش یوسف برای این query از تست روی ریپوی upstream به‌ارث رسیده و برای ریپوی ما غلطه. این مورد را از لیست باگ‌ها حذف کنید؛ اگر جای دیگری هم این ground truth استفاده می‌شه، اصلاحش کنید. + +## تایید نشده — نیاز به بررسی بیشتر + +### Latency outlier (۲۶ ثانیه روی `root_fs_safety`) +گزارش یوسف ادعا می‌کنه این query حدود ۲۶ ثانیه طول کشیده (هم روی build ویندوزی زیر Wine، هم روی Linux native). این session زمان دقیق call را اندازه نگرفت (فقط از طریق MCP زنده صدا زدیم، بدون timing). **قدم بعدی:** با `time` دقیق روی یک باینری تازه‌ساز، همین query رو چند بار اجرا کنید و ببینید تکرار می‌شه یا نه؛ اگر واقعی بود، پروفایل بگیرید (شاید regex یا walk روی مسیرهای root سیستم واقعاً کند باشه — `confine.rs` با `canonicalize` و چک‌های مسیر سر و کار داره). + +## تایید شده — باگ پروتکلی جدی (خارج از accuracy) + +### stdout pollution در حالت `mcp` +کد بررسی شد و **مستقیماً تایید شد**: +- `crates/neuromesh-api/src/server.rs:75-81` — بنر `🌿 NEUROMESH ... UI MONITOR & MCP DASHBOARD ACTIVE` با `println!` چاپ می‌شه (یعنی روی **stdout**) +- `crates/neuromesh-cli/src/main.rs:294-306` — در حالت `neuromesh mcp`، این `HttpServer` به‌صورت یک تسک tokio جدا (`tokio::spawn`) در **همون پروسه و همون stdout** که `McpServer::run_stdio()` (خط 318) روی آن پروتکل JSON-RPC خط‌به‌خط می‌فرسته، استارت می‌شه +- یعنی اگه دو تسک به هر دلیلی (تایمینگ race، یا هر بار) با هم روی stdout بنویسن، کلاینت‌های سخت‌گیر MCP (که خط اول رو مستقیم JSON.parse می‌کنن) می‌شکنن +- **فیکس پیشنهادی:** بنر را از `println!` به `eprintln!` (stderr) تغییر بده، یا کلاً در حالت `mcp` چاپش نکن (فقط در `neuromesh monitor` standalone چاپ بشه چون اونجا stdout مصرف‌کننده JSON-RPC نداره) + +## اولویت پیشنهادی برای فاز بعدی + +1. **فوری، کم‌ریسک:** فیکس stdout pollution (`println!` → `eprintln!` در `server.rs`) — یک‌خطی، بدون ریسک رگرسیون +2. **میان‌مدت:** ریشه‌یابی چرا این ۳ symbol (`confine`/`is_safe_workspace`, `reinforce_path`/`PheromoneEngine`, `FileCapArg`) روی این ریپوی خاص seed نمی‌شن — احتمالاً یه مسئله seed resolution برای نام‌های generic/چندجا-استفاده‌شده در یک ریپوی بزرگ Rust چندماژوله است؛ این می‌تونه با روش‌شناسی holdout قبلی (stage 4/5) بررسی بشه، یعنی این ۳ query رو به یه گلد-ست کوچیک self-repo اضافه کنید و دلیل دقیق seed miss رو پیدا کنید (شبیه F-numberهای قبلی) +3. **کم‌اولویت، نیاز به تایید:** latency 26s — قبل از هر کاری، reproduce کنید + +## منابع + +- گزارش اول یوسف (روی نسخه خیلی قدیمی fork با نام `code-context-engine`): دقت ۴۳٪، نشون داد نسخه قدیمی از upstream عقب بود +- گزارش دوم یوسف (روی باینری v1.0.0 فعلی ما، همین گزارش): دقت ۴۳٪ گزارش شده، همون query battery +- این handoff: نتیجه verify مستقیم با `mcp__neuromesh__get_context_packet` زنده روی همین ریپو، ۲۰۲۶-۰۹-۳۰