Octofs 0.9.0: लाइन 42 एक झूठ है
एजेंट ने लाइन 40 से 44 बदलने को कहा। उसे लाइन 40 से 44 ही मिलीं। पर ये वो लाइनें नहीं थीं जो उसने पढ़ी थीं।
कुछ क्रैश नहीं हुआ। कोई error नहीं दिखी। जिस view से प्लान बना और जिस batch_edit ने उसे चलाया, उनके बीच कहीं एक formatter चल गया और फ़ाइल तीन लाइन नीचे खिसक गई — और एडिट बिल्कुल साफ़-सुथरे ढंग से पाँच बेक़ुसूर लाइनों पर जा गिरा। मॉडल ने success response देखा, refactor को पूरा बताया और आगे बढ़ गया। हमें यह बीस मिनट बाद रिव्यू में मिला, एक ऐसा diff पढ़ते हुए जिसका कोई तुक नहीं बन रहा था।
यह रिलीज़ इसी failure mode को ख़त्म करने के लिए है।
0.9.0 हर लाइन के पते को content से verify करने लायक बनाता है: एक लाइन यानी N:hh — उसकी position, और उस पर जो लिखा है उसका hash — और हर edit tool एक भी byte लिखने से पहले उस hash को फ़ाइल से मिलाता है। पुराना target अब मौजूदा content के साथ साफ़-साफ़ fail होता है। वह ग़लत लाइन पर गिर ही नहीं सकता, क्योंकि "ग़लत लाइन" अब मैच ही नहीं करती।
इसके साथ दो और बदलाव आए हैं, और अंत में पता चलता है कि वे एक ही विचार के अलग-अलग कपड़े हैं। उस पर आख़िर में।
लाइन नंबर ग़लत primitive है
लाइन नंबर के बारे में बात यह है: वह सिर्फ़ उसी क्षण सच होता है जिस क्षण आपने उसे पढ़ा।
एजेंट का edit loop अलग-अलग MCP calls का सिलसिला है, जिनके बीच अंतराल होते हैं — और उन अंतरालों में फ़ाइल जमी हुई नहीं रहती। कोई दूसरा tool call उसे एडिट कर देता है। सेव पर formatter चल जाता है। कोई parallel agent उसी फ़ाइल को छू देता है। सेशन देख रहा इंसान एक typo ठीक कर देता है। जब तक एडिट पहुँचता है, "लाइन 42" उसी की ओर इशारा कर रही होती है जो संयोग से 42वें स्लॉट में बैठा है — और जो filesystem server सिर्फ़ एक नंगा integer लेता है, उसके पास यह जानने का कोई तरीक़ा नहीं कि मॉडल का इरादा किस लाइन का था और वह किस लाइन को अभी नष्ट करने वाला है।
आम उपाय है whole-file staleness gate: फ़ाइल देखे जाने पर उस पर मुहर लगाओ, और mtime या hash बदलने पर एडिट अस्वीकार कर दो। हमारे पास इसका एक संस्करण था। यह दोनों दिशाओं में भोथरा है। यह लाइन 900 का एडिट इसलिए ठुकरा देता है क्योंकि किसी ने लाइन 3 छू दी, और उबरने के लिए पूरी फ़ाइल दोबारा पढ़वाता है — महँगा, और वह दोबारा पढ़ा हुआ भी तुरंत पुराना हो जाता है। इससे भी बुरा यह कि वह कुछ काम की बात नहीं कहता। "फ़ाइल बदल गई" के बाद मॉडल के पास एक ही चाल बचती है: पूरी फ़ाइल फिर से पढ़ो और उम्मीद करो कि इस बार race जीत जाओगे।
Primitive ही ग़लत था। लाइन के reference में इतनी जानकारी होनी चाहिए कि वह ख़ुद को जाँच सके।
N:hh — position और उसका सबूत
0.9.0 में view हर लाइन को N:hh|content के रूप में दिखाता है:
1:a3|fn main() {
2:f1| println!("Hello");
3:0e|}
N है 1 से शुरू होने वाली position। hh है दो hex अक्षर — लाइन के content का FNV-1a hash, 32 bits से मोड़कर 8 bits किया हुआ। Edit tools इन्हीं composite IDs को target के रूप में वापस लेते हैं, और verify_line_id लागू करने के वक़्त hash को फ़ाइल से मिलाता है। मैच हुआ तो एडिट आगे बढ़ता है। नहीं हुआ तो कुछ भी नहीं लिखा जाता।
Hash सिर्फ़ content पर बनता है, position पर कभी नहीं। लिखते वक़्त यह एक छोटा-सा विवरण लगा था, और निकला पूरा डिज़ाइन। चूँकि लाइन खिसकने पर भी अपना hash बनाए रखती है, इसलिए असफल verification यह ढूँढ़ने जा सकता है कि content गया कहाँ — फ़ाइल में अपेक्षित hash वाली लाइनें स्कैन करो, और आपको पता चल जाता है कि target ग़ायब नहीं हुआ, बस तीन लाइन नीचे सरक गया।
Error ठीक यही कहती है:
Stale line id "42:c7" — the file changed since you viewed it. Current content around line 42:
40:1b| let config = load_config()?;
41:9f| let client = Client::new(&config);
42:2e| tracing::info!("client ready");
43:0a|
44:5d| run(client).await
Content matching hash c7 is now at: 45:c7 (your target may have moved).
Retry with the fresh ids above, or run `view` with start: 40, end: 44 (or a wider range) to confirm before editing.
इस संदेश में तीन चीज़ें हैं, और हर एक सोच-समझकर रखी गई है। Target के आसपास का मौजूदा content, ताज़ा IDs के साथ — ताकि मॉडल तुरंत दोबारा निशाना लगा सके। अपेक्षित hash से मेल खाता content अब कहाँ है, सबसे नज़दीकी उम्मीदवार पहले — ताकि खिसकी हुई मगर बाक़ी अनछुई लाइन एक ही क़दम में ठीक हो जाए। और एक ठोस view range, अगर वह अंदाज़ा लगाने के बजाय पुष्टि करना चाहे।
मॉडल सिर्फ़ error के दम पर उबर जाता है। 2,000 लाइन की फ़ाइल दोबारा नहीं पढ़नी पड़ती, दूसरी race नहीं होती, context नहीं जलता। Error ही recovery का निर्देश है।
और चूँकि एडिट के नतीजे ताज़ा गणना किए गए IDs वाले diffs के रूप में लौटते हैं, इसलिए एडिट एक-दूसरे से जुड़ जाते हैं। लगातार तीन batch_edit calls करिए और दूसरे के targets पहले के response से आ जाते हैं — बीच में फ़ाइल को दोबारा देखने की ज़रूरत ही नहीं पड़ती।
एक ईमानदार trade-off है, और वह छिपाया नहीं गया — source में comment के रूप में लिखा है: आठ bits का मतलब है कि बदली हुई लाइन 1/256 की संभावना से अपना hash बनाए रख सकती है। हमने यह मंज़ूर किया। IDs इतने छोटे रहते हैं कि context में सस्ते पड़ें और transcript में पढ़े जा सकें, position की जाँच हर बड़े खिसकाव को पकड़ लेती है, और विकल्प — हर फ़ाइल view की हर लाइन पर लंबे hashes — 0.4% वाले उस मामले से बचाव के लिए हर पढ़ाई पर tokens ख़र्च कराता, जिसे "ताज़ा IDs वाला diff" लूप वैसे भी अक्सर सामने ले आता है।
हमने mode switch भी हटा दिया। पिछले संस्करणों में --line-mode flag था, जो number-based और hash-based addressing के बीच चुनाव करता था। वह flag अब नहीं है, और N:hh फ़ॉर्मैट अनिवार्य है — यही 0.9.0 का breaking change है। दो addressing modes का मतलब था कि हर tool description को दोनों समझाने पड़ते, हर मॉडल को यह पता लगाना पड़ता कि वह किससे बात कर रहा है, और सुरक्षित mode opt-in रहता। जो सुरक्षा किसी flag के पीछे भेजी जाती है, वह ऐसी सुरक्षा है जिसे ज़्यादातर लोग कभी चालू नहीं करते।
सादे integers वहाँ अब भी चलते हैं जहाँ अकेली position वाक़ई सुरक्षित है और स्वभाव से ही verify करने लायक कुछ नहीं होता: view की ranges (ऋणात्मक मान अंत से गिने जाते हैं), और insert anchors 0 यानी फ़ाइल की शुरुआत और -1 यानी अंत में जोड़ना। जो कुछ भी मौजूदा content को target करता है, उसे ID चाहिए।
वह एडिट जो सफल हुआ और जिसने कुछ नहीं किया
जब हम अंदर थे ही, तो उसी बग का एक और चुपचाप वाला रूप मिल गया।
str_replace चरणों में मैच करता है: पहले exact, फिर whitespace-normalized fuzzy पास — उन मौक़ों के लिए जब मॉडल की indentation खिसक गई हो। CRLF line endings वाली फ़ाइल में fuzzy पास normalized text पर मैच पा लेता था, फिर replacement को वापस raw content में बिठाने की कोशिश करता था — जहाँ हर लाइन अब भी \r\n पर ख़त्म होती थी, यानी बिठाने को कुछ था ही नहीं। फ़ाइल byte-दर-byte वैसी की वैसी लिखी जाती और जवाब में diff के साथ success आ जाता। Windows पर काम कर रहे डेवलपर का एडिट पूरी प्रक्रिया से गुज़रता और बदलता कुछ नहीं।
अब सारी matching LF space में होती है और restore_endings लिखते वक़्त \r\n वापस लगा देता है। batch_edit में भी यही। फ़ाइल अपने line endings बनाए रखती है; matcher को उनसे कोई मतलब नहीं रह जाता।
0.9.0 में पूरी match सीढ़ी ऐसी है: exact → escaped-literal recovery → indentation adjustment के साथ whitespace-normalized fuzzy → diagnostics। दूसरा चरण नया है और पूरी तरह इसी सोच से बना है कि मॉडल किस तरह ग़लती करते हैं: जब कोई मॉडल अपने JSON को दो बार escape कर देता है और newline की जगह literal backslash-n भेजता है, तो हम escapes की व्याख्या कर लेते हैं, और अगर उससे मैच अनोखा निकलता है तो उसे लागू करके एक hint जोड़ देते हैं कि हमने क्या किया। यह ग़लती मॉडल लगातार करते हैं, होने पर यह असंदिग्ध होती है, और इसके लिए error वापस भेजना एक पूरा round trip उस चीज़ को सुधारने में ख़र्च करा देता था जो असल में टूटी ही नहीं थी।
replace_all भी नया है — rename वाली वह एडिट जिसके लिए पहले या तो इतना आसपास का context चाहिए होता था कि हर occurrence अनोखा बने, या फिर हर जगह के लिए एक operation वाला batch_edit। और जब replace_all के बिना exact match कई बार मिलता है, तो error अब हर जगह को line ID के रूप में गिनाती है:
Found 3 matches for replacement text at:
1. 12:a3
2. 88:a3
3. 140:a3
Add more surrounding context to make a unique match, pass `replace_all: true` to replace all 3 occurrences, or use `batch_edit` with the specific line ids.
तीन नामज़द रास्ते, और हर एक बिना दूसरे view के चलाया जा सकता है। वही पैटर्न, फिर से।
Hints सलाह होते हैं, और मॉडल सलाह चुन-चुनकर मानते हैं
तीसरा बदलाव किसी न किसी को खटकेगा, तो इसका पक्ष रख देता हूँ।
Octofs shell के दुरुपयोग को पहचानता है — यानी जब मॉडल cat, grep, find, ls, sed या awk की ओर हाथ बढ़ाता है, जबकि उस काम को कोई समर्पित MCP tool बेहतर करता है। 0.9.0 से पहले यह पहचान --hint-mode से configure होती थी: नरमी से चेताओ, या अस्वीकार कर दो। 0.8.1 से नरम वाला default था।
नरम hints काम नहीं करतीं। सफल response के साथ चिपकी चेतावनी एक ऐसा सुझाव है जो उस नतीजे से मुक़ाबला कर रहा है जो मॉडल के हाथ में पहले से है — और नतीजा जीत जाता है। हमने ऐसे सेशन देखे जिनमें वही hint छह बार चली और मॉडल grep चलाता रहा, क्योंकि grep output लौटा रहा था और hint को नज़रअंदाज़ करने की कोई क़ीमत नहीं थी।
0.9.0 में shell का दुरुपयोग हमेशा hard error है, और mode switch हटा दिया गया है। Call fail होती है, कुछ execute नहीं होता, और error बताती है कि किस tool का इस्तेमाल करना है — हल किए हुए उदाहरण के साथ:
Searching file text with this command is forbidden — use `view` with content= instead
(gitignore-aware, context lines, line numbers, works on remote hosts).
Example:
view path="src/main.rs" content="fulfill_input_requests"
view path="src/" content="TODO" regex=true
view path="ssh://user@host/dir" content="TODO" # remote search — no `ssh grep` needed
यह सफ़ाई-पसंदगी नहीं है। content= के साथ view वे line IDs लौटाता है जिन्हें edit tools स्वीकार करते हैं, .gitignore का सम्मान करता है, और ssh:// paths पर पारदर्शी ढंग से चलता है। कच्चा grep output मॉडल को एक line number देता है — यानी ऊपर कही हर बात के हिसाब से, एक झूठ जो अपने वक़्त का इंतज़ार कर रहा है — और उसे चुपचाप node_modules में डुबो देता है। समर्पित tool हर लिहाज़ से ज़्यादा उपयोगी है, तो सवाल बस इतना था कि उसे चुनना वैकल्पिक रखा जाए या नहीं। अब नहीं है।
जो अब भी चलता है: pipelines। cargo build 2>&1 | grep error एक stream transform है, फ़ाइल पढ़ना नहीं, और detector जान-बूझकर | पर command को नहीं तोड़ता। वह ;, &&, ||, newlines, $( और backticks पर तोड़ता है — और सिर्फ़ quotes के बाहर, ताकि ssh host 'cd /path && ls' किसी remote command पर false positive न दे, जिससे detector का कोई लेना-देना नहीं। असली प्रोग्राम तक पहुँचने के लिए env-var prefixes छोड़ दिए जाते हैं, और /bin/grep को grep तक सीधा कर दिया जाता है, ताकि path लगाना बचकर निकलने का रास्ता न बन जाए।
इन तीनों में समान क्या है
तीनों को एक साथ देखिए और यह एक ही बदलाव है, तीन बार किया हुआ।
MCP server का असली interface उसका tool schema नहीं है — वह हर वह string है जो वह मॉडल को लौटाता है, और उनमें से ज़्यादातर strings errors होती हैं। इंसान के लिए error एक सूचना है; आप उसे पढ़ते हैं और जाकर हाथ से चीज़ें ठीक करते हैं। एजेंट के लिए error एक prompt है। वही अगले फ़ैसले का पूरा input है, बिना किसी और context के आता है, और आगे जो होगा उसकी गुणवत्ता उसी string की सामग्री से बँधी हुई है।
तो: यह मत कहिए कि "फ़ाइल बदल गई" — बताइए कि किसमें बदली और content कहाँ चला गया। यह मत कहिए कि "3 matches मिले" — उन्हें ऐसे targets के रूप में गिनाइए जिन्हें अगली call सीधे इस्तेमाल कर सके। यह मत जताइए कि grep से बचना चाहिए — fail होइए और वही सटीक view call थमा दीजिए जो उसकी जगह लेती है। इनमें से हर एक वही चाल है: error में दो-चार सौ अक्षर ख़र्च करो और एक पूरा round trip तथा उसमें जलने वाला context बचा लो।
जब हम कहते हैं कि हम server को filesystem के बजाय मॉडल के हिसाब से align कर रहे हैं, तो मतलब यही होता है। यह prompt engineering नहीं है। यह उस caller के लिए interface डिज़ाइन है जो पढ़कर उबरता है — और सिर्फ़ वही पढ़ता है जो आप उसे देते हैं।
अपग्रेड
# Homebrew
brew upgrade muvon/tap/octofs
# Cargo
cargo install octofs --version 0.9.0
Linux, macOS और Windows (x86_64 और ARM64) के लिए तैयार binaries releases पेज पर हैं, और release pipeline अब crates.io तथा MCP registry के साथ-साथ npm पर भी publish करता है।
एक breaking change जिसका पता होना चाहिए: अगर आपके MCP client config में --line-mode या --hint-mode सेट है, तो उन्हें हटा दें — दोनों flags जा चुके हैं और binary उन्हें अस्वीकार कर देगा। इनकी जगह लेने को कुछ नहीं है; सुरक्षित व्यवहार ही अब एकमात्र व्यवहार है। बाक़ी कोई config बदलाव नहीं।
अपग्रेड के बाद फ़र्क़ पहली ही ऐसी एडिट पर दिखता है जो किसी और चीज़ से टकराती है। ग़लत लाइनों पर चुपचाप मिलने वाली सफलता की जगह आपको ऐसी error मिलेगी जिस पर आपका एजेंट फ़ाइल दोबारा पढ़े बिना काम कर सकता है।
Octofs ओपन सोर्स है (Apache 2.0), github.com/Muvon/octofs पर। अगर आपको सुरक्षा वाला नज़रिया चाहिए — कि एजेंट को कच्चा shell देने के बजाय सीमित filesystem tool क्यों देना चाहिए — तो वह अलग लेख है; यह लेख इस बारे में था कि tool ठीक उसी लाइन को एडिट करे जिस पर उसे इशारा किया गया था।



