שרת ה-MCP — קריאה, פרסור וחיפוש בתוכן
העמוד הזה הוא חלק מתיעוד שרת ה-MCP. התשתית, טבלת הכלים וטבלת הקבועים — ב-שרת ה-MCP — חיבור Claude ל-CodeKeeper.
ענף שלא במראה, מול קובץ שלא קיים
מה המראה מחזיקה, ומתי היא נמשכת מ-GitHub — ראו רענון אוטומטי (autosync).
כלי שמקבל ``ref`` לעולם אינו מושך. בקשה לענף שאינו במראה עונה שהוא אינו שם, ולא מפעילה git fetch — אחרת כל קורא היה יכול להפעיל משיכות מ-GitHub. אילו ענפים נכנסים למראה היא מדיניות של ה-autosync בלבד.
מה הכלים עונים כשהקריאה לא מגיעה לקובץ. ההבחנה נולדת במקום אחד, _validate_ref_with_git ב-services/git_mirror_service.py, ועוברת דרך resolve_commit ו-get_file_at_commit לכל כלי הריפו. codekeeper_list_repo_tree פותר את הענף באותה בדיקה לפני ls-tree, וקורא את העץ מה-commit שחזר.
|
מתי, ומה בתשובה |
מה הקורא עושה |
|---|---|---|
|
|
לתקן את השם; |
|
השם לא עבר את הבדיקה הבסיסית ( |
לתקן את השם |
|
השם תקין, והמראה של המארח הזה לא מכירה אותו — ענף או SHA. ”לא במראה“, ולא ”לא קיים“: המראה יודעת רק מה היה ב-GitHub במשיכה האחרונה שלה. נושא |
לא לנסות שמות קבצים אחרים. ענף שנדחף לאחרונה יגיע עם השינוי הבא בענף הראשי |
|
הענף נפתר, והקובץ אינו ב-commit שלו. נושא |
לבדוק את הנתיב מול ה-commit שב- |
|
למארח אין עותק של הריפו כלל |
עניין של המפעיל |
|
הריפו נמצא והקריאה נכשלה: מראה שבורה, timeout, או כשל אחר של git |
לנסות שוב מאוחר יותר; אם זה חוזר — המפעיל |
sync_in_progress גובר על כל אלה חוץ מ-invalid_ref: קריאה שנכשלה בזמן שסנכרון רץ אולי ראתה את המראה באמצע משיכה, ו“נסה שוב“ נכון יותר מ“לא במראה“. שם פגום אינו תלוי בסנכרון, ולכן הוא נענה מיד.
codekeeper_search_repo ופריטי codekeeper_read_batch אינם מקבלים ref — הם קוראים תמיד את הענף הראשי — ולכן ref_not_mirrored מגיע מהם רק כשהענף הראשי עצמו חסר במראה, למשל כשהמטא-דאטה במונגו מצביעה על ענף שאין בה. בבאץ« ref_not_mirrored הוא תשובה רגילה ואינו נרשם בלוג, כמו invalid_ref.
פגום, חסר ושבור — לפי קוד היציאה של git. git rev-parse --verify --quiet יוצא ב-1 כשהשם אינו נפתר לרוויזיה אחת (die_no_single_rev ב-builtin/rev-parse.c), וכל תקלה אחרת עוברת דרך die() ויוצאת ב-128 (usage.c) — שניהם ב-git v2.43.0. יציאה 1 היא ref_not_mirrored, וכל קוד אחר הוא git_error, שנרשם כ-WARNING עם ה-stderr ומגיע לכלים כ-read_failed. כך מראה שנמחקה, או תיקייה שאינה ריפו, אינה נקראת ”ענף שלא נמשך“. יציאה 1 מכסה גם שני מצבים נוספים, ונמדד: ref שמצביע על אובייקט שאינו commit (תגית של עץ), ו-ref שהאובייקט שלו חסר במאגר. בשניהם ה-commit אכן אינו במראה.
הוובאפ צורך את אותה בדיקה. /repo/api/history ו-/repo/api/file-at-commit/<commit> מחזירים 404 על ref_not_mirrored, 400 על invalid_ref ו-500 על git_error. הממשק עצמו אינו מציע בחירת ענף, ולכן משתמש לא מגיע לזה משם. tests/test_repo_browser_ref_errors.py קורא את רשימת הקודים מקוד המקור של הבדיקה, ונופל על קוד שאין לו סטטוס באחת משתי המפות.
איזה קובץ docs_get_section קורא, ובאיזה ריפו
לכל ריפו יש מדיניות נתיבים משלו: איפה התיעוד שלו יושב, ובאיזה פורמט.
ריפו |
שורש |
סיומת |
דוגמה ל-slug קצר |
|---|---|---|---|
|
|
|
|
|
שורש הריפו |
|
|
שני שערים, ולא אחד. MCP_DOCS_REPO הוא מה שהפריסה מתירה בזמן ריצה;
הטבלה שלמעלה, שחיה ב-mcp_server/docs_handlers.py, היא מה שהקוד יודע
לקרוא. ריפו חייב לעבור את שניהם. ריפו שנמצא במשתנה הסביבה ואין לו מדיניות
נדחה ב-repo_not_configured ו**אינו** נופל לברירת מחדל מתירנית — אחרת ריפו
שמישהו הוסיף להגדרות היה מוגש תחת כללים שאיש לא החליט עליהם.
בלוק ה-``instructions`` נוקב בריפואים שעוברים את שני השערים, ובאותו סדר: המשפט ”It serves …“ נבנה בעליית השרת מ-MCP_DOCS_REPO ומהטבלה (served_docs_repos ב-mcp_server/docs_handlers.py), כך שריפו שמותר ואין לו מדיניות אינו מופיע בו, ושינוי של משתנה הסביבה משנה את הבלוק בלי שינוי קוד.
חשוב
הכניסה הראשונה ב-``MCP_DOCS_REPO`` היא ברירת המחדל, ולכן היא קובעת גם
את הפורמט. קריאה שאינה נוקבת ב-repo מקבלת את השורש והסיומת של אותו
ריפו. שינוי הסדר במשתנה הסביבה הוא שינוי התנהגות, לא סידור: אותו
path="mcp-server" יחפש docs/mcp-server.rst בסדר אחד ו-
mcp-server.md בשני.
כלל ה-slug. קלט בלי / מקבל את השורש ואת הסיומת של הריפו; קלט
שכבר יש בו / נלקח כמות שהוא, ורק הסיומת מתווספת אם היא חסרה. מכאן ששני
דברים נכונים בו-זמנית: בריפו ששורשו docs/ קובץ בתת-תיקייה דורש את הנתיב
המלא (docs/observability/error_codes, לא observability/error_codes),
ובריפו ששורשו שורש הריפו הנתיב המקונן עובד מאליו
(bugbot-rules/race-toctou).
בקשה לפורמט שהריפו אינו מגיש נדחית בשמה. .md תחת CodeBot, או
.rst תחת amir-bug-patterns, מחזירים:
{"ok": false, "error": "suffix_not_allowed", "repo": "CodeBot",
"requested_path": "CRITICAL-PATTERNS.md", "allowed_suffixes": [".rst"]}
הסיומת אינה מושלמת בשקט מעל הקיימת. אחרת הבקשה הייתה הופכת ל-
docs/CRITICAL-PATTERNS.md.rst, חוזרת כ-not_found, והקורא היה מחפש את
הבעיה בקובץ במקום במדיניות.
הערה
הסדר: עגינה, אחריה נרמול, ורק אז בדיקת הגבול — והגבול נבדק כיחידת
נתיב ולא כרצף תווים. docsecret/x אינו תחת docs/ אף שהוא מתחיל
באותם תווים, ו-docs/../secrets נדחה כי הוא נעגן לפני שנפתר,
ולכן הנרמול מוציא אותו אל מחוץ לשורש במקום לתוכו. מנגד
bugbot-rules/../README.md מתקבל — הוא נפתר אל תוך הריפו,
ו-.. אינו מילה אסורה אלא נרמול שנבדק לפי לאן הוא מגיע.
ושורש ריק אינו ”הכול“. הוא ”כל דבר בתוך הריפו“, והסיומת עדיין מסננת:
ב-amir-bug-patterns כל קובץ .md נגיש בכל עומק, כולל תחת
.claude/ — וזו החלטה מודעת, על ריפו ציבורי שכל 93 קובצי ה-.md בו
הם מסמכים שנועדו לקריאה. .claude/settings.json אינו נגיש, כי הוא
אינו .md.
נתיב ארוך מדי נדחה לפני שמישהו קורא אותו. path מעל 4,096 תווים מקבל
path_too_long עם max_chars ו-actual_chars, והסירוב קורה בשלב
הראשון של פתרון הנתיב — לפני מדיניות הסודות, לפני המראה, ולפני הפרסור. הנתיב
הארוך ביותר בכל הריפואים שמוגשים כאן קצר מ-120 תווים, ולכן התקרה אינה חוסמת
שום קובץ אמיתי; מה שהיא חוסמת הוא העבודה שגדלה עם אורך הקלט, ובמסלול הזה
העבודה הזאת היא סריקת הרכיבים של מדיניות הסודות. זו הקטנת משטח ולא הגבלת
קצב — היא מגבילה את המחיר של בקשה אחת, לא את מספר הבקשות; ראו אישו #3430.
ו-``ref`` ארוך מדי נדחה גם הוא, בכל שלושת הכלים שמקבלים אותו. ref מעל MAX_REF_CHARS — אותו מספר, ומאותו נימוק — מקבל ref_too_long עם max_chars ו-actual_chars, בלי ה-ref עצמו (ראו ענף שלא במראה, מול קובץ שלא קיים). הוולידציה של השם נשארת במקום אחד, BASIC_REF_PATTERN; התקרה רק מונעת מ-ref ענק לחזור בתשובה ולהפוך אותה לגדולה מהתקציב.
ועוד שני סירובים שנוקבים בסיבתם, משתי סיבות שהתחזו לאחרות (#3432).
path_outside_root — הנתיב תקין בצורתו ופותר אל מחוץ לשורש התיעוד של
הריפו (docs/../secrets, /etc/hosts, ../README.md בריפו ששורשו
ריק); התשובה נושאת את root, שממילא כתוב בתיאור הכלי. עד היום זה חזר
כ-missing_path, והקורא לא ידע אם שכח נתיב או ניסה לצאת מהשורש;
missing_path נשאר לנתיב ריק בלבד. ו-repo_not_mirrored — למארח אין
עותק של הריפו כלל (כל כלי הריפו מחזירים אותו באותו מצב — ראו ענף שלא במראה, מול קובץ שלא קיים).
עד היום גם זה חזר כ-not_found, וסוכן שביקש עמוד אמיתי ניסה שם אחר בזמן
שהבעיה הייתה אצל המפעיל. אם sync רץ באותו רגע, התשובה היא
sync_in_progress כמו תמיד.
הסירובים שמגיעים מהפרסור עצמו, וכולם error ולא כשל כללי:
inconsistent_line_endingsבקובץ יש
\rשאינו חלק מ-\r\n— הפורמט של Mac שלפני 2001. שם שכבר קיים באוצר המילים:outlineמחזיר אותו כ-reasonעל אותה מחלקת קלט בדיוק. בקובץ כזה ספירת השורות אינה חד-משמעית, ומפה שמצביעה לשורה שאי אפשר לבקש גרועה ממפה שאין. CRLF עובד במלואו. את הקובץ עצמו אפשר לקרוא כולו, מדופדף, ב-whole_document=true.too_many_linesקובץ Markdown ארוך מ-
MAX_LINESשורות (services/md_parser.py; הערך ב-הקבועים, התקרות ואוצר המילים של התשובה). נבדק לפני הפרסור, ולכן הסירוב אינו עולה דבר. התשובה נושאתmax— התקרה — ו-total_lines, מספר השורות באותה יחידה כמו כל שדהlinesבתשובות ה-MCP;lineאין בה, כי הפרסור לא התחיל. קובץ כזה אפשר לקרוא בטווחים, ב-codekeeper_get_repo_fileעםlines(אדמין), או כולו, מדופדף, ב-whole_document=true. למה תקרת שורות ולא רק תקרת טוקנים: חלק מהזיכרון שלmarkdown-it-pyנגזר ממספר השורות ולא ממספר הטוקנים — מערכים עם ערך לכל שורה שנבנים לפני שכלל כלשהו רץ, הגדרות קישור שנשמרות בלי טוקן, וציטוט מקונן ששומר רשימות לכל שורה בכל רמה. קובץ כזה יכול לעלות את כל ההקצאה לחוט עם כמה עשרות טוקנים בלבד.too_many_tokensהפרסור הגיע ל-
MAX_TOKENSטוקני בלוק (services/md_parser.py; הערך ב-הקבועים, התקרות ואוצר המילים של התשובה). התקרה נבדקת ברגע שנוצר כל טוקן — גם באמצע טבלה ובאמצע רשימה, שנבנות בקריאה אחת לכלל שלmarkdown-it-py— ולכן הפרסור נעצר באמצע, ולא אחרי שכל העבודה כבר נעשתה. התשובה נושאתmaxואתline, השורה שהפרסור הגיע אליה (בליlineכשעוד לא נוצר טוקן שיש לו מיקום). את הקובץ אפשר לקרוא כולו, מדופדף, ב-whole_document=true. למה היא נחוצה גם אחרי השדרוג ל-4.2.0: התקרה של upstream לטבלה שמשלימה תאים ריקים (MAX_AUTOCOMPLETED_CELLS, #364) סופרת לכל טבלה, והמונה מתאפס בכל טבלה חדשה — קובץ של 128KB עם הרבה טבלאות כאלה הגיע ל-3GB בלי התקרה ונפל ב-MemoryError.איך נבחרו שתי התקרות (#3391) — יחד, משני תנאים שחייבים להתקיים בו-זמנית: הקלט העוין הגרוע שנמצא בתקרת הקריאה נכנס בעלות שמאגר הקריאות מקצה לפרסור אחד (
_PARSE_COST_BYTESב-mcp_server/server.py; ראו מודל הריצה של הכלים), וגם אף קובץ אמיתי אינו נחסם — לא ב-docs/, לא בכל הריפו, לא ב-amir-bug-patterns ולא בקבצים השמורים. המרווח יושב בשורות ולא בטוקנים, בכוונה: הקובץ האמיתי הארוך ביותר קרוב לתקרה שלו יותר מהצפוף ביותר לשלו, וקובץ שגדל בשורות הוא התרחיש השכיח. הקלט, המדידות, התאריך והסקריפט שמשחזר אותן — לידMAX_LINESו-MAX_TOKENSב-services/md_parser.py;scripts/measure_md_parse_cost.pyבודק את שני התנאים מחדש בכל שינוי של הפרסר או של התקרות.too_many_sectionsהקובץ חוצה את תקרת הכותרות — 50,000 בשני המסלולים, ובאותה דרך: ברירת המחדל של
max_sectionsבשני הפארסרים היאMAX_SECTIONSשלservices.doc_sections, והכלי אינו מעביר תקרה משלו. (עד #3420 ברירת המחדל שלrst_parserהייתה ללא תקרה, והכלי העביר לו את_ceiling.MAX_SYMBOLSבמפורש מאז #3429; היישור נעשה אחרי מדידה על כל 208 קובצי ה-RST — הקובץ העשיר ביותר נושא 50 סקשנים, אחד לאלף מהתקרה — ופלט הכלי על כולם זהה לפני ואחרי.) התקרה של סורק האאוטליין,_ceiling.MAX_SYMBOLS, היא אותו מספר, וטסט מחזיק את השניים שווים. ב-Markdown היא אינה יכולה להיפגע עם ברירות המחדל (מאז #3391): כל כותרת פותחת בשורה משלה, ו-MAX_LINESקטן מ-MAX_SECTIONS— כלומר קובץ שהיה חוצה אותה נעצר קודם, ב-too_many_lines. עד #3391 זה היה הפוך: Markdown עוין של שורות-תבליט בלי אף כותרת עבר אותה בלי שום סירוב, כי היא סופרת כותרות. היא נשארת בשביל RST, ובחוזה המשותף של שני הפרסרים. גם כאן את הקובץ אפשר לקרוא כולו, מדופדף, ב-whole_document=true.
כשהפארסר מספק מספר שורה, התשובה נושאת גם line — השורה שגרמה לסירוב,
1-מבוססת, כפי שהפארסר עצמו חישב אותה. זה מה שהופך את הסירוב לפעולה:
inconsistent_line_endings בלי מספר אומר ”יש איפשהו \r בקובץ של 4,000
שורות“. md_parser מספק אותו ל-inconsistent_line_endings, ל-too_many_tokens
ול-too_many_sections; too_many_lines נושא במקומו את total_lines, כי
הפרסור לא התחיל; rst_parser מרים TooManySections בלי מספר, ולכן סירוב RST
בא בלי השדה. השדה מופיע רק כשיש מספר, ולא כ-null — כמו
suggestions_truncated ו-remaining_chars באותו כלי.
כל התשובות האלה נושאות repo, path, ref ו-resolved_commit, כדי
שאפשר יהיה לפתוח בדיוק את הקובץ שסירב.
``includes`` הוא שדה של RST. הוא מונה יעדי .. include::, ול-Markdown
אין צורה כזאת — ולכן בעמוד .md הוא תמיד []. אפס שם אינו ”לא
בדקנו“ אלא ”אין מה לבדוק“.
ותקרת הגודל היא זו של שירות המראה, 500KB. הכלי קורא את הקובץ כולו, בלי
lines ובלי outline, ולכן קובץ גדול יותר מחזיר
unreadable_too_large ולא תשובה חתוכה — גם במצב מסמך. למדידה: הקובץ הגדול ביותר ב-
amir-bug-patterns הוא 107KB, כלומר מרווח של פי 4.7.
קובץ שלם: whole_document=true. סעיף מתחיל בכותרת, ולכן שני דברים אינם מגיעים מאף סעיף: מה שלפני הכותרת הראשונה — front matter, הקדמה, תווית RST — וקובץ שיש בו כמה כותרות עליונות, שאינו תת-עץ של אף אחת מהן. וסוכן שהגיע עם נתיב, למשל מטבלת טריגרים, אינו יודע מה הכותרת הראשונה בלי קריאה נוספת למפה. במצב מסמך הכלי מחזיר את הקובץ כולו, משורה 1: mode: document, עם content, offset, max_chars כפי שנאכף, truncated, ו-remaining_chars ו-next_offset כשנשאר עוד. זה העימוד של סעיף, מאותה פונקציה (_text_page ב-mcp_server/docs_handlers.py), כולל עמוד שנגמר מוקדם על byte_budget (איך docs_get_section מוצא כותרת). התשובה נושאת repo, path, ref ו-resolved_commit כמו כל תשובה של הכלי, ו-encoding — התווית שהפענוח במראה נתן, באותה משמעות כמו file.encoding (קריאת טווח שורות): latin-1 שם הוא אזהרה ולא זיהוי. אין בה breadcrumb, subsections, neighbors, includes ו-section_count, כי כולם תוצרים של פרסור — ובמצב מסמך אין פרסור. לכן ארבעת הסירובים שלמעלה אינם חלים בו, וקובץ שהפרסר מסרב לו נקרא שלם. מה שכן חל הוא כל מה שקודם לפרסור, באותן תשובות בדיוק: מדיניות הנתיבים שבראש הסעיף, מדיניות הסודות, הריפו, ref ותקרת הגודל. content הוא מה ש-codekeeper_get_repo_file מחזיר על אותו קובץ ו-ref — אותה קריאה, אותו פענוח ואותה הסרת BOM — ולכן שום קובץ שלא נקרא היום סעיף-סעיף אינו נקרא במצב הזה. וקריאה מלאה ב-codekeeper_get_repo_file שאינה נכנסת בתקציב (answer_too_large) מפנה לכאן, כשכלי התיעוד מגיש את הקובץ: read_with הוא שם הכלי, ו-read_with_arguments נושאים whole_document: true — מי שביקש את הקובץ כולו מקבל אותו כולו, ולא מפה שממנה צריך עוד לבחור סעיפים. ההפניה של outline על קובץ בלי מפה נשארת למפת הכותרות, כי שם ביקשו מפה. section יחד עם הדגל נדחה ב-section_and_whole_document (גם section ריק), ו-max_depth ב-max_depth_and_whole_document, שניהם לפני כל בדיקה של ריפו או נתיב; ל-include_subsections אין משמעות במצב הזה, ואי אפשר לדעת אם נשלח. בלי הדגל התשובה זהה למה שהייתה לפניו.
ראו גם מדיניות סינון סודות — מדיניות הסודות חלה על הכלי הזה במלואה.
איפה מפת ה-Markdown חולקת על GitHub
המפה ש-services.md_parser בונה נמדדת מול cmark-gfm — הפארסר ש-GitHub מריץ — על צורות מחוללות, ב-tests/test_md_parser_oracle.py. הפערים הידועים והמדודים יושבים בטבלה אחת בקוד, _KNOWN_DIVERGENCES, וכל שורה בה היא סיבה ולא רשימת מופעים. הטבלה כאן מעתיקה ממנה, והטסט test_the_documentation_table_is_this_table משווה ביניהן בכל שורה בנפרד: הסיבה, המספרים, הגרסאות והתאריך. מה שכל צד מחזיר על הצורה לדוגמה נבדק מול שני הפארסרים עצמם, בטסט אחר.
הסיבה |
צורה לדוגמה |
מה GitHub מחזיר |
מה אנחנו מחזירים |
נמדד |
upstream |
|---|---|---|---|---|---|
תגית HTML מסוג 7 צמודה לשורת מכל (פריט רשימה או ציטוט) |
|
סעיף אחד: |
שני סעיפים: |
12 מתוך 26 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
https://github.com/executablebooks/markdown-it-py/issues/434 |
רשימת תגיות הבלוק של CommonMark 0.31.2: source/search |
|
עם |
עם |
28 מתוך 60 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
אין אישו — זה שינוי במפרט: |
טבלה שעוברת את תקרת התאים המשלימים של markdown-it-py 4.x |
|
סעיף אחד: כל השורות בטבלה, ו- |
שני סעיפים: הטבלה נחתכת בתקרה, השורות שנשארו בחוץ הן פסקה, ו- |
2 מתוך 8 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
אין אישו — זו תקרה מכוונת, שנוספה ב-markdown-it-py 4.0.0 (#364, https://github.com/executablebooks/markdown-it-py/pull/364). ב-3.0.0 אין תקרה, ושם המפה הסכימה עם GitHub. |
כותרת setext שצמודה להגדרת קישור |
|
הכותרת מתחילה בשורה 1, שורת ההגדרה |
הכותרת מתחילה בשורה 2, שורת הכותרת עצמה |
92 מתוך 720 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
אין אישו. הפער קדם לשדרוג: 3.0.0 מחזיר בדיוק את אותן מפות. |
תו רווח ש-markdown-it-py מזהה ו-cmark-gfm לא, אחרי שם תגית HTML |
|
שני סעיפים: אצל cmark-gfm השורה היא פסקה, ו- |
סעיף אחד: אצלנו השורה פותחת בלוק HTML, ו- |
2,316 מתוך 5,075 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27 |
אין אישו. הפער קדם לשדרוג: 3.0.0 מחזיר בדיוק את אותן מפות. |
”N מתוך M צורות שנמדדו“ הוא מדידה, לא גבול. M הוא מספר הצורות שהמחולל של אותה שורה בטבלה מייצר, ו-N הוא כמה מהן חולקות על GitHub. על צורה שלא נמדדה המספר אינו אומר דבר.
איך הפערים נמצאו. שתי השורות הראשונות נמדדו לפני השדרוג ובמהלכו. שלוש האחרונות נמצאו בסקירה של השדרוג: אף מחולל לא בנה טבלה רחבה כל כך, הגדרת קישור או תגית עם רווח כזה. מאז כל אחת מהן היא משפחה קבועה באורקל, ולידן משפחה של כל תגית בלוק ותגית מסוג 1, בכל צורות הכתיבה של _HTML_TAG_FORMS ובכל ההקשרים של השורה השנייה. כך שינוי ברשימת התגיות של markdown-it-py יתגלה בשדרוג הבא בלי שמישהו יחשוד בו מראש: test_the_two_block_tag_lists_differ_only_by_the_known_row נופל, וגם הצורות של השם שנוסף או הוסר.
הפער של source/search בפירוט. נמדדו 60 צורות: 2 תגיות, 6 צורות כתיבה (פתיחה, סגירה, עם מאפיין, באותיות גדולות, עם טקסט אחרי התגית, ולא סגורה) ו-5 הקשרים. מה שמכריע הוא אם השם נמצא ברשימה (בלוק HTML ”מסוג 6“, שקוטע פסקה) או לא (לכל היותר ”סוג 7“, שדורש תגית שלמה ולבדה בשורה ואינו קוטע פסקה):
תגית שלמה ולבדה בשורה, מיד אחרי שורת פסקה — פער בשתי התגיות, בכיוונים הפוכים.
אותה תגית מיד אחרי שורת פריט ברשימה — פער ב-
sourceבלבד. אצלנו היא סוג 7, וזה בדיוק המנגנון של השורה הראשונה בטבלה (#434). כלל השיוך: צורה ששורתה מתחילה ב-<sourceאו ב-<searchשייכת לשורה השנייה, גם כשהמנגנון בפועל הוא של הראשונה — כך כל צורה יושבת בשורה אחת בדיוק, וטסט אוכף זאת.תגית עם טקסט אחריה (
<source> טקסט) או לא סגורה (<source) — פער בכל הקשר מחוץ לציטוט, גם אחרי שורה ריקה.בתוך ציטוט — אין פער.
ופער אחד נסגר בשדרוג: <search> שלמה ולבדה, מיד אחרי שורת פריט ברשימה. ב-markdown-it-py 3.0.0, שבו search עוד לא הייתה ברשימה, זה היה מופע של #434 — ## אחרי נשאר כותרת אצלנו ונבלע אצל GitHub. מאז 4.0.0 search פותחת בלוק HTML שקוטע את הפריט, כמו אצל GitHub, ושני הצדדים מחזירים סעיף אחד. גם זה מקובע בטסט (closed בשורה של הטבלה).
הטבלה שעוברת את התקרה בפירוט. מ-4.0.0, markdown-it-py מגביל את מספר התאים שהוא משלים בטבלה אחת: MAX_AUTOCOMPLETED_CELLS (65,536) ב-markdown_it/rules_block/table.py. שורת גוף שיש בה פחות תאים משורת הכותרות מקבלת את התאים החסרים, והספירה מצטברת משורה לשורה. השורה שמעבירה אותה מעל התקרה כבר אינה בטבלה: הטבלה נגמרת שם, וכל השורות שנשארו בחוץ הן פסקה. --- או === שבא מיד אחריהן הופך את הפסקה לכותרת setext, ברמה 2 או 1. ב-cmark-gfm אין תקרה כזאת: כל השורות בטבלה, והקו שאחריה אינו יוצר כותרת. המשפחה היא טבלה שבדיוק בתקרה וטבלה שעוברת אותה בשורה אחת, ואחרי כל אחת כל אחד מההמשכים של _TABLE_CAP_FOLLOWERS: ---, ===, שורת טקסט, או סוף הקובץ. פער יש רק כשהטבלה נחתכה וגם קו setext בא אחריה. הקובץ לדוגמה בטבלה הוא 1,682 בתים בלבד: כדי לעבור את התקרה לא צריך קובץ גדול, מספיקה טבלה רחבה. ב-3.0.0 אין תקרה, וכל הצורות מסכימות. והפער הזה אינו נגיש דרך הכלי: הפער מתחיל רק אחרי 196,608 טוקנים — שלושה לכל תא משלים עד התקרה — והכלי מסרב לכל מסמך שעובר את MAX_TOKENS ב-too_many_tokens, לפני שהוא מגיע לשם. האורקל משווה בלי התקרות של md_parser, בכוונה: הוא שואל איך הפרסר מבין את הטקסט, והתקרות הן מדיניות משאבים שקובעת אם הכלי יפרסר קובץ בכלל, ולא מה הוא רואה בו (#3391).
הגדרות הקישור בפירוט. המשפחה היא כל צירוף של הקשר שלפני ההגדרה, צורה של הגדרה, ומה שבא מיד אחריה (_reference_cells). יש שלושה סוגי פער:
82 — אותה כותרת, בשורה אחרת. כותרת setext שמתחילה מיד אחרי הגדרה. cmark-gfm מדווח את השורה שבה ההגדרה מתחילה: אצלו הכותרת נבנית מהפסקה שהתחילה בשורת ההגדרה. כשמגיע קו ה-setext, הוא מוציא מהפסקה את ההגדרה והופך את מה שנשאר לכותרת, והכותרת שומרת את שורת ההתחלה של הפסקה (
open_new_blocksב-src/blocks.c). אנחנו מדווחים את השורה של הכותרת עצמה.6 — כותרת שיש רק ב-GitHub.
[a]:בלי יעד, ומיד אחריה===. ``markdown-it-py`` קורא את===כיעד של ההגדרה, ואין כותרת. cmark-gfm מנסה לקרוא את[a]:לבדה כהגדרה, לא מצליח, ומקבל כותרת setext שהטקסט שלה[a]:.4 — כותרת שיש רק אצלנו. הגדרה מוזחת לתוך פריט רשימה, ואחריה שורת טקסט וקו setext בלי הזחה. אצלנו הפריט נגמר אחרי ההגדרה, והקו הופך את שורת הטקסט לכותרת. cmark-gfm ממשיך את הפסקה בתוך הפריט, והקו הוא קו מפריד.
הפער קדם לשדרוג: 3.0.0 מחזיר בדיוק את אותן מפות, והשכתוב של כלל ההגדרות ב-4.0.0 (#367, תיקון של סיבוכיות ריבועית) לא הזיז אף צורה.
הרווח שאינו רווח בפירוט. markdown-it-py כותב עם \s של פייתון גם את תנאי הפתיחה של בלוק HTML (HTML_SEQUENCES ב-markdown_it/rules_block/html_block.py) וגם את התגית עצמה (markdown_it/common/html_re.py). cmark-gfm 0.29.0.gfm.13 מקבל שם רק את spacechar שלו (src/scanners.re): רווח, טאב, טאב אנכי, מעבר דף ושני התווים של מעבר שורה. תו ש-\s מזהה ו-spacechar לא — למשל רווח שאינו נשבר (NBSP), שיכול להגיע בהעתקה מדף אינטרנט — פותח אצלנו בלוק HTML, ואצל cmark-gfm הוא אינו חלק מתגית בכלל, והשורה היא פסקה. לכן בכל פער הכיוון אחד: כותרת שיש ב-GitHub ואין אצלנו. זה קורה ב-23 תווים (_PYTHON_ONLY_SPACES), ב-61 תגיות הבלוק (כל שם ברשימה של אחד הפארסרים, חוץ מ-source ו-search), ב-4 התגיות מסוג 1, ובתגיות מסוג 7:
תגית בלוק או תגית מסוג 1, והתו צמוד לשם — פער בכל הקשר מחוץ לציטוט. אצלנו השורה פותחת בלוק שקוטע פסקה.
תגית בלוק או תגית מסוג 1, ורווח רגיל כבר הפריד את השם — אין פער. שני הצדדים פתחו בלוק על הרווח הרגיל.
כל תגית אחרת, כולל תגית סגירה של סוג 1 — פער רק אחרי שורה ריקה או מיד אחרי כותרת. אצלנו זו תגית שלמה מסוג 7, והיא אינה קוטעת פסקה.
בתוך ציטוט — אין פער.
גם הפער הזה קדם לשדרוג, ו-3.0.0 מחזיר בדיוק את אותן מפות. הטריגר של השורה הזאת מסמן רק תו כזה מיד אחרי שם התגית. המשפחה מראה פער גם כשהתו יושב בין מאפיינים או לפני > של תגית מסוג 7, אבל סריקה של שורה אחת אינה יכולה להבדיל שם בין רווח לבין תו בתוך ערך של מאפיין, בלי לכתוב מחדש את הדקדוק של תגית.
קבצים אמיתיים — האם מישהו מושפע:
עמודי ה-``.md`` תחת ``docs/``, ב-CI — הטסט
test_no_markdown_page_under_docs_holds_a_known_divergence_triggerסורק כל עמוד, ונופל על כל שורה שמפעילה שורה בטבלה שיש לה טריגר (triggerב-_KNOWN_DIVERGENCES). שני סוגי שורות אינם נסרקים, והפארסר עצמו הוא שקובע אותם ולא כלל שנכתב ביד: שורה בתוך בלוק קוד (גדר, קוד מוזח, front matter), ושורה בתוך בלוק HTML שנפתח בשורה קודמת — כמו<source>בתוך<video>, שהיא חלק מהבלוק של<video>ואינה פותחת בלוק משלה. הפטור לא חל כשהבלוק אולי קיים רק אצלנו: כששורת הפתיחה שלו היא בעצמה טריגר, או כשיש בה, בכל מקום, תו רווח ש-cmark-gfm אינו מכיר (השורה החמישית). קבצים שתולים בטסטים מוכיחים את שני הצדדים: תגית מחוץ לבלוק נתפסת ובתוכו לא,<source>בתוך<video>עוברת ו-<source>לבדה נופלת, שורת טריגר בתוך בלוק שנפתח רק אצלנו נתפסת, ו-NBSP מיד אחרי שם של תגית נתפס. עמוד שאינו UTF-8 מפיל את הסריקה עם שם הקובץ, ותיקייה בלי אף עמוד היא כישלון ולא הצלחה.כל קורפוס, על פי דרישה —
scripts/compare_md_parser_to_cmark.pyמשווה את המפה של כל קובץ ל-cmark-gfm, ובאותה ריצה סורק טריגרים באותה פונקציה של ה-CI. כל אי-הסכמה וכל שורת טריגר מפילות את הריצה. לשורות בטבלה שאין להן טריגר, השוואת המפות הזאת היא הבדיקה היחידה על קבצים אמיתיים, כי ה-CI סורק רק טריגרים. נמדד ב-2026-09-27, על markdown-it-py 4.2.0:docs/— 34 קבצים, 358 כותרות, אפס אי-הסכמות ואפס שורות טריגר.amir-bug-patterns, בקומיט456e90b— 97 קבצים, 1,240 כותרות, אפס אי-הסכמות ואפס שורות טריגר. ה-CI אינו רואה את הריפו הזה.הריפו הזה כולו — כל קובץ
.mdשנמצא ב-git, כוללnode_modules: 427 קבצים, 9,547 כותרות, אפס אי-הסכמות ואפס שורות טריגר.
הקבצים השמורים — נמדדו פעם אחת במסד, בקריאה בלבד, ב-2026-09-27, ורק באוסף
code_snippets(האוסףlarge_filesלא נסרק): 981 מסמכי Markdown פעילים (כל גרסה היא מסמך; Markdown לפי שפה או סיומת) ו-143 בסל. השיטה הייתה שונה מזו של ה-CI: ביטוי רגולרי במסד על תחילת שורה,^ {0,3}</?(source|search)(\s|/?>|$), כלומר רק השורה השנייה בטבלה, ובלי הפארסר. כל פגיעה נבדקה ביד. נמצאה אחת: במסמך פעיל אחד יש שורה שמתחילה ב-<source src=...>, והיא בתוך בלוק קוד מגודר, ולכן אינה משפיעה על המפה. בסל לא נמצא אף אחד. השורה החמישית לא נמדדה במסד. ה-CI אינו רואה את המסד, ולכן זו מדידה מתוארכת ולא טסט. ולקבצים שמורים ההשוואה הרלוונטית היא ממילא הוובאפ ולא GitHub: הוובאפ מרנדר עםhtml: false, ושם כל שורת HTML מתנהגת אחרת מאצלנו — הבדל דיאלקט רחב יותר, שאינו חלק מהטבלה הזאת. איפה בדיוק, עם דוגמה מקובץ שמור אמיתי — ב-איפה המפה חולקת על הוובאפ.
איך docs_get_section מוצא כותרת
הכלי מקבל section ומחזיר את הסעיף הזה בלבד. ההתאמה נעשית בשני שלבים,
ובסדר הזה בדיוק.
קודם שוויון מלא על טקסט הכותרת, אחרי נרמול סלחני: רווחים בקצוות
ורווחים כפולים מתכווצים, צורות המקף מאוחדות, והאותיות הלטיניות מושוות בלי
תלות ברישיות. חלק מכותרת אינו מתאים לה — section="טבלה" לא ימצא
את טבלה מרכזית.
ואם השוויון לא מצא כלום, ורק אז, נבדקת התאמת מזהה. היא נדלקת רק
כשהשאילתה עצמה בנויה כמזהה — עד שלוש אותיות ואחריהן עד שלוש ספרות,
עם נקודה אופציונלית בסוף (K11, K11., U3, P3) — ואז מוחזרת
הכותרת שנפתחת באותו מזהה, כשאחריו נקודה, רווח, או סוף הכותרת. שאילתה
שאינה בנויה כך אינה עוברת במסלול הזה כלל, ולכן section="איך" ממשיך
להחזיר אפס התאמות גם כשיש בעמוד חמש-עשרה כותרות שמתחילות במילה הזאת.
הערה
מזהה נקרא כיחידה, לא כקידומת של מחרוזת. section="K1" מחזיר את
K1 בלבד, ולעולם לא את K10 עד K15 — הכותרת K10. טקסט
נקראת כמזהה K10, והוא אינו שווה ל-K1. זה חשוב כי הצורה
השבורה של הבדיקה הזאת (השוואת תווים במקום השוואת יחידות) היא אותה
מחלקת באג שמתוארת ב-amir-bug-patterns תחת K16.
ומאותה סיבה, נקודה שפותחת תת-מספור אינה גבול. בכותרת K11. טקסט
הנקודה מסיימת את המזהה; בכותרת K11.1 טקסט היא מפרידה בתוך מזהה
ארוך יותר. לכן section="K11" מחזיר את K11. בלבד ולא את
תת-הסעיפים שלו, וכותרת בתת-מספור נמצאת בשמה המלא בלבד — היא אינה
בצורת המזהה שהחוזה מגדיר.
מזהה שחוזר פעמיים באותו עמוד הוא ``ambiguous_section``, עם רשימת
המועמדים — בדיוק כמו כל כותרת כפולה. הכלי אינו מנחש. והרשימה חסומה ב-50
מועמדים, אותו מספר ומאותה סיבה כמו ההצעות: מלאי בסדר הופעה ולא דירוג,
גבוה מספיק שכל עמוד אמיתי ייענה במלואו. אם היו יותר, התשובה נושאת
candidates_truncated: true — ורק אז, כמו suggestions_truncated. לפני
התקרה זה היה השדה היחיד בתשובה בלי גבול: עמוד סינתטי של 512KB עם 13,030
כותרות שנפתחות באותו מזהה החזיר 13,030 מועמדים ו-1.4MB על שאילתה בת שלושה
תווים (#3426).
ולדגל יש שתי סיבות, והמועמדים תמיד בסדר המסמך. הרשימה נחתכת בתקרה שלמעלה, או מוקדם יותר כשהתשובה לא הייתה נכנסת ב-OUTPUT_BYTE_BUDGET בתים כפי שהיא נשלחת — מועמדים שכל אחד מהם נושא breadcrumb ארוך עלולים לעבור אותו. בשני המקרים החיתוך הוא מהסוף, ולכן המועמד שחסר הוא תמיד מהאחרונים בעמוד, ו-candidates_truncated: true אומר שהכותרת שחיפשתם עשויה להיות ביניהם. היא מופיעה במפת הכותרות, עם line_range ועם השם המלא — אלא אם גם המפה נחתכה. השם המלא מגיע אליה כשהשאלה הייתה מזהה, כי הוא שוויון ולא קיצור, אבל רק כשאין עוד כותרת באותו שם. בלי הסדר הקבוע, חיתוך היה מעלים מועמד שרירותי, וסירוב שמסתיר את מה שחיפשו בלי לומר איך מגיעים אליו הוא סירוב מטעה.
כותרת ששמה המלא חוזר בעמוד לעולם אינה נענית בשמה. שאלה בשם הזה היא ambiguous_section שוב, בלי קשר לחיתוך — וכותרות כאלה אינן נדירות: ב-amir-bug-patterns אותה כותרת משנה חוזרת תחת דפוס אחרי דפוס, וגם בקבצים שמורים ב-Ck יש לא מעט כאלה. לכן כל מועמד נושא breadcrumb ו-line_range, ומהם קוראים אותה. לפי line_range: ב-codekeeper_get_file עם lines, שמחזיר בדיוק את טקסט הסעיף, ובעמוד תיעוד עם codekeeper_get_repo_file (אדמין). או דרך הורה מה-breadcrumb, בשמו — כשהשם שלו אינו חוזר — כי סעיף כולל כברירת מחדל את תת-הסעיפים שלו. וכשהכותרת אינה ברשימת המועמדים וגם לא במפה, כי שתיהן נחתכו, query של codekeeper_get_file מחזיר את השורות שמחזיקות את הטקסט שלה, ו-lines קורא משם.
ותקרה על מספר הסקשנים בעמוד — אותה תקרה של האאוטליין, 50,000. עמוד
שחוצה אותה מוחזר כ-{"ok": false, "error": "too_many_sections", "max": 50000}
— בלי עץ כותרות חלקי, והפרסור נעצר במקום שבו התקרה נחצתה ולא אחריו. אף
עמוד אמיתי אינו מתקרב לזה: הכלי קורא עד 500KB, והעמוד הצפוף ביותר בריפו
מחזיק 6.5 סקשנים ל-KB — כ-3,300 בתקרת הקריאה. מה שהתקרה עוצרת הוא קלט
שנבנה בכוונה, כותרת בת תו אחד בכל שורה: בלעדיה הוא עולה 44.3MiB לפרסור
אחד, יותר ממה שמאגר הקריאות מקצה לחוט, ואיתה הוא נעצר ב-20.2MiB (נמדד מחדש
ב-2026-09-27, עם איפוס VmHWM; החשבון ב-מודל הריצה של הכלים).
ובעמוד Markdown עוד שתי תקרות, מאז #3391: too_many_lines לפני הפרסור,
ו-too_many_tokens ברגע שנוצר כל טוקן — כי קובץ עוין בלי אף כותרת אינו נראה
לתקרת הסקשנים בכלל. עם שתיהן על ברירת המחדל, תקרת הסקשנים אינה יכולה להיפגע
ב-Markdown. מה כל אחת עוצרת ואיך נבחרו — ב-איזה קובץ docs_get_section קורא, ובאיזה ריפו.
ותקרה על השאילתה עצמה: section ארוך מ-4,096 תווים נדחה ב-
section_too_long, עם max_chars ו-actual_chars — אותם שמות שדות כמו
path_too_long — לפני כל התאמה, ובלי להדהד את מה שנדחה ובלי עץ כותרות.
ההתאמה של שאילתה שאינה נמצאת (השוויון, ואז ההצעות מול כל כותרת) גדלה עם אורך
השאילתה, והשאילתה חוזרת במלואה ב-requested: בלי התקרה, שאילתה בגודל של גוף
בקשה מלא החזירה תשובה גדולה מ-OUTPUT_BYTE_BUDGET ועלתה מאות אלפיות מעבד
מעל הפרסור, כלומר שברה את החשבון של מגבלת הקצב. המבחן הוא האורך ולא התוכן,
ולכן גם שאילתה של רווחים בלבד מעל התקרה נדחית. התקרה רחוקה מכל כותרת אמיתית
שנמדדה — בעמודי התיעוד, ב-amir-bug-patterns ובקבצים השמורים — והמדידה עצמה
יושבת ליד MAX_SECTION_CHARS ב-mcp_server/docs_handlers.py. הבדיקה בפונקציה
שבונה את התשובה, ולכן היא חלה באותה צורה על codekeeper_read_batch ועל
codekeeper_get_file עם section (קריאה לפי סעיף בקובץ Markdown שמור (toc ו-section)).
וכותרת בודדת חסומה באורכה — ``MAX_TITLE_CHARS``, אותו מספר כמו ``MAX_SECTION_CHARS`` ומקובע אליו בטסט. כותרת ארוכה מהתקרה נחתכת על גבול תו — בלי לשבור אשכול (ניקוד, ZWJ, בורר-וריאציה) — ומסומנת title_truncated. הקיצוב במקור, לפני בניית ה-breadcrumb, ולכן הוא מכסה בבעלים אחד את הכותרת עצמה, כל עותק שלה ב-breadcrumb, ואת section, candidates, subsections ו-neighbors: בכל מקום שבו נפלטת כותרת של סעיף, title_truncated נלווה אליה כשהיא נחתכה. כך נחסם מגבר ה-breadcrumb בשורש — כותרת-אב פתולוגית אינה נכתבת שוב במלואה בכל צאצא. ומכאן התנהגות חדשה, שנולדת מהתקרה ולא מקלט המשתמש: שתי כותרות שונות שחולקות קידומת ארוכה מהתקרה נחתכות לאותה מחרוזת, ולכן בקשה לפי אותה מחרוזת היא ambiguous_section עם מועמדים — כמו כל שם כפול, ונקראת דרך ה-line_range של המועמד. התקרה רחוקה מכל כותרת אמיתית שנמדדה — בעמודי התיעוד, ב-amir-bug-patterns ובקבצים השמורים — ולכן על תוכן אמיתי היא אינה נדלקת והפלט אינו משתנה.
ורכיבי ה-breadcrumb הם הכותרות כפי שהן במפה — מקוצצות כשהן מעל התקרה, כי שתי הצורות נבנות מאותה כותרת שכבר נחתכה. איפה לבדוק אם רכיב נחתך: title_truncated יושב על הרשומה של הכותרת עצמה במפה, ומי שרואה רכיב breadcrumb שנראה חלקי מוצא את אותה כותרת במפה ורואה שם אם קוצצה. אין דגל פר-רכיב, ואין בו צורך: רכיב ה-breadcrumb הוא בדיוק אותה מחרוזת כמו הכותרת, ולכן הדבקתו ל-section מתנהגת בדיוק כמו הדבקת הכותרת — מתאימה, או ambiguous_section כשהשם משותף.
ועמוד של סעיף חסום בבתים, כפי שהוא נשלח. max_chars סופר תווים, אבל
התקציב — OUTPUT_BYTE_BUDGET — הוא בתים: תו עברי הוא שני בתים, תו CJK
שלושה, אימוג’י ארבעה, ותו בקרה שישה ב-JSON. לכן עמוד מלא של תווים רחבים עבר
את התקציב (עמוד CJK של MAX_CHARS_MAX תווים יצא ב-299,373 בתים). עמוד שאינו
נכנס נגמר מוקדם יותר, על גבול תו, ואינו נדחה: truncated הוא true,
next_offset הוא איפה שהעמוד באמת נגמר, ו-truncation_reason הוא
byte_budget — ורק אז, כדי שהקורא ידע למה קיבל פחות מ-max_chars.
ממשיכים מ-next_offset כמו תמיד, ושום תו אינו הולך לאיבוד בין עמוד לעמוד.
המדידה היא על התשובה כולה, באותה פונקציה שמודדת את הבאץ«
(wire_json ב-mcp_server/answer_size.py), ולא בספירת בתים של התוכן
לבדו. עמוד שנכנס —
וכל עמוד בקורפוס של התיעוד נכנס, גם ב-MAX_CHARS_MAX — אינו משתנה בבית אחד.
ו-``subsections`` חסומה באותה תקרה של המפה (_TOC_MAX), עם
subsections_truncated: true רק כשנחתכה. בלעדיה זה היה השדה היחיד בתשובה
בלי גבול: סעיף עם 7,000 תת-סעיפים יצא בתשובה של כמיליון בתים. ואם הכותרות
שהתשובה נושאת גדולות מהתקציב לבדן, כך שגם עמוד ריק אינו נכנס, התשובה היא
section_too_large, עם bytes, max ו-line_range — ולא עמוד ריק, כי
next_offset שאינו זז היה שולח קורא ממושמע ללולאה אינסופית. את הסעיף עצמו
קוראים אז ב-lines לפי line_range.
ושלוש התשובות האחרות — המפה, ``section_not_found`` ו-``ambiguous_section`` — חסומות באותו תקציב, בחיתוך מהסוף. לרשימות שלהן אין offset, ולכן הן לא נגמרות מוקדם ואז ממשיכות: הן נחתכות עד שהתשובה כולה נכנסת, במדידה של wire_json, והדגל של כל רשימה נדלק — toc_truncated, suggestions_truncated, candidates_truncated. לכל דגל יש עכשיו שתי סיבות: הרשימה הגיעה לתקרה שלה במספר פריטים (_TOC_MAX למפה, 50 להצעות ולמועמדים), או נחתכה מוקדם יותר כדי שהתשובה תיכנס בתקציב. הקורא עושה אותו דבר בשני המקרים: כותרת שחסרה במפה עדיין נקראת בשמה כשאין עוד כותרת באותו שם — כותרת ששמה חוזר נקראת דרך המועמדים, כמו למעלה — ו-query מוצא את השורות שמחזיקות את הטקסט שלה; section_count נשאר מספר הכותרות כולן, כך שרואים כמה חסר. ב-section_not_found המפה נחתכת לפני ההצעות — המפה היא מלאי, וההצעות הן התשובה לשאלה שנשאלה — וההצעות מתקצרות רק אחרי שהמפה התרוקנה. מפה בגודל של כמה פעמים הקובץ אינה תיאורטית: כל פריט בה נושא את כל ה-breadcrumb שלו, ובקינון עמוק אותה כותרת נכתבת שוב ושוב. תשובה שגם כשכל הרשימות שלה ריקות אינה נכנסת היא answer_too_large, עם bytes ו-max: max הוא OUTPUT_BYTE_BUDGET עצמו, ו-bytes הוא המספר שהושווה אליו, ולכן bytes > max תמיד — כך גם ב-section_too_large. מה שנשאר בתשובה אז הוא ההקשר — המטא-דאטה של קובץ שמור, או includes של עמוד RST. הסירוב נושא את ההקשר כמו כל תשובה, ובהקשר של קובץ שמור יש שני דברים שמותר לו לוותר עליהם: רשימת התגיות, מהסוף, עם file.tags_truncated, ואחריה התיאור — שלם, ובמקומו file.description_bytes (ראו קריאה לפי סעיף בקובץ Markdown שמור (toc ו-section)). כל השאר — שם של קובץ, includes של עמוד — אינו נחתך, ולכן כשהוא לבדו גדול מהתקציב גם הסירוב גדול ממנו, והרשת של תקרת התשובה — מה כלי מצהיר, ומי אוכף היא שעוצרת אותו. וב-``codekeeper_get_file``, כשהתגיות או התיאור הם מה שלא נכנס, הכלי אינו מחזיר את הסירוב הזה: הוא בונה את התשובה שוב בלעדיהם, ומחזיר אותה עם הסימנים (ראו תקרת התשובה — מה כלי מצהיר, ומי אוכף). תשובה שנכנסת אינה משתנה בבית אחד: על עמודי התיעוד, על amir-bug-patterns ועל קובצי ה-Markdown של הריפו, התשובות המלאות של שלוש הצורות זהות לפני ואחרי החיתוך הזה.
ומפה של הרמות העליונות בלבד — ``max_depth``. בקובץ שכל פרק בו חוזר על אותן תת-כותרות — כמו RECURRING-PATTERNS.md ב-amir-bug-patterns, שבו ”איך זה נראה“, ”כלל לזיהוי“ ו“ראה גם“ יושבות תחת כל דפוס — המפה המלאה היא בעיקר חזרות, ומי שרוצה לבחור פרק צריך רק את הפרקים. max_depth=2 מחזיר את השורש ואת הפרקים בלבד. העומק נספר לאורך ה-breadcrumb, ולא לפי ``level``: השורש בעומק 1, ובמארקדאון ### שבא מיד אחרי # הוא ילד ישיר, בעומק 2 — סינון לפי level היה מעלים אותו ואת כל מה שתחתיו. ב-RST השניים שווים תמיד, כי הפרסר דוחה כותרת שמדלגת רמה. המפה מוגבלת בכוונה, וזה לבדו משאיר את ``toc_truncated`` כבוי: הדגל נדלק רק כשהמפה המוגבלת עצמה נחתכת, משתי הסיבות שלמעלה. הסימן להגבלה הוא hidden_subsections: כותרת בגבול העומק נושאת אותו, עם מספר הכותרות שהוסתרו תחתיה, וכותרת בגבול שלא הוסתר תחתיה דבר אינה נושאת אותו. כותרת רדודה מהגבול אינה נושאת אותו לעולם — הכותרות שהוסתרו מתחתיה כבר נספרות על הכותרות שבגבול — ולכן ב-max_depth=1 השדה יושב על השורש, וב-max_depth=2 על הפרקים. section_count — שקיים רק בתשובת המפה (mode: toc), ולא ב-section_not_found — נשאר מספר הכותרות כולן, וכל עוד המפה המוגבלת לא נחתכה (toc_truncated כבוי), סכום כל hidden_subsections ועוד אורך המפה שווה לו בדיוק; כשהיא נחתכה, הכותרות שנחתכו והצאצאים שנספרו עליהן אינם בתשובה, והסכום קטן ממנו. max_depth חוזר בכל תשובה שנושאת מפה כערך שנאכף — הוא נצמד לטווח שבין MAX_DEPTH_MIN ל-MAX_DEPTH_MAX ואינו נדחה, ולכן 0 הוא 1 — וערך עמוק מהקובץ אינו מסתיר דבר: התשובה זהה לתשובה בלעדיו, חוץ מההד. ההגבלה היא של המפה בלבד. היא חלה גם על המפה שבתשובת section_not_found, אבל לא על ההצעות שבאותה תשובה — הן נבחרות מהמסמך כולו — ולא על המועמדים של ambiguous_section ולא על ההתאמה: כותרת שהתבקשה בשמה נמצאת בכל עומק, ובתשובה שאין בה מפה max_depth אינו מהודהד. והסינון קודם לתקרה _TOC_MAX ולתקציב הבתים, ולכן מפה שהייתה נחתכת בגלל כותרות עמוקות מגיעה שלמה ברמות העליונות. בלי הפרמטר — המפה המלאה, בלי max_depth ובלי hidden_subsections.
אזהרה
כותרת שנכתבה עם סימון literal דורשת את הבקטיקים גם בשאילתה.
הכותרות מוחזרות כטקסט המקור ולא כטקסט מרונדר, ולכן שם שמופיע בכותרת
בתוך בקטיקים כפולים חייב להגיע כך גם ב-section:
section = ``codekeeper_get_file`` ← מוצא
section = codekeeper_get_file ← לא מוצא, ונראה כמו באג
זה נוגע להרבה כותרות בעמודי התיעוד כאן. הדרך הבטוחה: קריאה בלי
section מחזירה את עץ הכותרות, ומשם מעתיקים את השם המדויק.
וכששאילתה אינה נמצאת, suggestions מחזיר קודם כול כותרת שקרובה למה
שהוקלד, כשיש כזו בעמוד. ורק כשאין אף כותרת קרובה הוא מחזיר במקומה את
המזהים שכן קיימים באותו עמוד. כלומר שאילתה בצורת מזהה יכולה לחזור דווקא עם
כותרת, ואין לקרוא את השדה כאילו הוא תמיד רשימת מזהים.
הסדר הזה מכוון, ולא מקרי: מחרוזת יכולה להיות בצורת מזהה בלי להיות מזהה
בעמוד הזה — H2 הוא אות וספרה, ובעמוד שיש בו כותרת H2O זו הכותרת
שתחזור. כותרת אמיתית שהקורא יכול להעתיק היא תשובה טובה יותר מרשימת מזהים
שאינה קשורה לשאלה.
והכמות: עד 50 הצעות. זה נכון לשני המקרים — כותרת קרובה או רשימת
מזהים — ואם היו עוד, התשובה נושאת suggestions_truncated: true. הדגל
אומר ”היו עוד וחתכנו“, ולא משנה איזה משני המסלולים החזיר את התשובה. והוא נדלק גם כשההצעות נחתכו כדי שהתשובה תיכנס ב-OUTPUT_BYTE_BUDGET — וזה קורה רק אחרי שהמפה שבאותה תשובה כבר התרוקנה.
ולמה דווקא 50 ולא מספר קטן ונוח יותר: רשימת המזהים אינה מדורגת. היא עונה על ”ביקשת מזהה שאינו קיים, אלה שכן“, ולשאלה הזאת אין תשובה ”חמש הטובות ביותר“ — חיתוך שלה מסתיר פריטים בלי שום קריטריון, ולקורא אין דרך לבקש את השאר. לכן התקרה גבוהה מספיק כדי שעמוד רגיל ייענה במלואו.
מפת סימבולים (outline)
codekeeper_get_repo_file עם outline=true מחזיר את המפה של הקובץ
במקום את התוכן: כל פונקציה וכל מחלקה, עם שורת התחלה וסיום. משם ממשיכים
ל-lines=[start, end] וקוראים בדיוק את מה שצריך.
{"ok": true,
"status": "outline",
"file": {"path": "webapp/app.py", "ref": "refs/heads/main",
"resolved_commit": "aca7599", "size": 805594, "lines": 20035,
"encoding": "utf-8"},
"symbols": [{"name": "api_db_collections", "start": 5421, "end": 5481}],
"total": 530, "page": 1, "per_page": 100}
זו התשובה במלואה, לא קטע ממנה: ok ו-file תמיד שם, ואין שדה
truncated.
הערה
אין כאן ``truncated``, ובכוונה. ב-codekeeper_list_repo_tree המילה
הזו פירושה ”תקציב הפלט חתך רשומות בתוך העמוד“ — לא ”יש עמוד נוסף“.
באאוטליין אין חיתוך כזה: עמוד שאינו נכנס בתקציב נדחה במלואו ולא
מוחזר חלקית, כי חיתוך בתוך עמוד יחד עם עימוד אריתמטי מאבד סימבולים —
העמוד נעצר באמצע והבא מתחיל אחרי per_page המלא.
הגודל נמדד על הסריאליזציה האמיתית ב-UTF-8, ולא בספירת תווים. גרסה
קודמת חסמה את אורך השם ב-200 תווים והסיקה מכך שהעמוד נכנס — וזה
שגוי פעמיים: תו CJK הוא שלושה בתים, כך שעמוד מקסימלי הגיע ל-323,000
בתים מול תקציב של 256,000; וקיצוץ השם הרס את הזהות, כך ש-symbol=
עם השם המלא כבר לא מצא את הסימבול.
שדה שתמיד false ומסמן משמעות אחרת מזו של הכלי השכן גרוע מהיעדרו.
מה שאומר אם יש עוד עמודים הוא ``total`` מול ``page`` ו-``per_page``.
מגבלת הגודל. אאוטליין נשפט מול אותה תקרה של קריאת טווח — 10MB, ולא 500KB — כי הוא קורא את הקובץ ומחזיר פלט זעיר. ראו קריאת טווח שורות, והמספרים עצמם — כולל תקרות העימוד — ב-הקבועים, התקרות ואוצר המילים של התשובה.
``lines`` ו-``outline`` אינם מצטברים. קריאה שמעבירה את שניהם מוחזרת
כ-{"ok": false, "error": "outline_and_lines"}, ולא מתעלמת בשקט מאחד
מהם. בקשו מפה, ואז טווח.
השם מלא, עם נקודות. ClassName.method, outer.inner,
_register_repo_tools.get_repo_file. הכלל אחד: מרחב שמות בפייתון הוא
פונקציה או מחלקה, ולכן התחילית גדלה רק בהם. if, try, with
ו-for אינם מרחב שמות — פונקציה שהוגדרה בתוך except ImportError
מופיעה ברמה שלה, בלי תחילית.
הערה
הכלל הזה הוא כל הפיצ’ר. בלעדיו mcp_server/server.py החזיר תשעה
סימבולים במקום 38, ו-build_mcp נראתה כבלוק אטום של 513 שורות שכל
כלי ה-MCP חבויים בתוכו. גרסת ביניים שירדה רק לתוך גופי פונקציות עדיין
החמיצה 62 סימבולים ב-webapp/app.py, כי מחלקות ה-fallback שם יושבות
בתוך except ImportError.
שורת ההתחלה היא של המעטר. ב-webapp/app.py 203 מתוך 408 הפונקציות
ברמה העליונה מעוטרות; טווח שהיה מתחיל ב-def היה מחמיץ את
@app.route(...), כלומר בדיוק את השורה שמזהה את הנתיב.
``symbol=`` מסנן על השם המלא, ללא תלות ברישיות. לכן
symbol="build_mcp" מחזיר גם את הפונקציה וגם את כל מה שמוגדר בתוכה —
”תן לי הכול תחת המרחב הזה“. total סופר את ההתאמות אחרי הסינון, כי
עליו נשען העימוד. על webapp/app.py, symbol="api_db" מחזיר חמישה
סימבולים במקום 530.
והוא חל על כל שם שהמפה מחזירה, לא רק על המנוקדים — וזה מה שהופך
אותו לכלי המרכזי בקבצים לא-פייתון. symbol="@media" על קובץ CSS גדול
מחזיר את שאילתות המדיה בלבד, כלומר בדיוק את המובייל וה-RTL עם הטווחים
שאפשר להמשיך מהם ל-lines=, במקום כל בלוקי הסלקטור. בתבנית,
symbol="block " מחזיר את תגיות {% block %} בלבד, ו-symbol="#"
את כל מה שנושא id — גם עוגני HTML בצורת tag#id וגם סלקטורי id
מתוך <style>, כי הסינון הוא בהכלה ולא בתחילית.
שמות אינם ייחודיים. if/else אינם מרחב שמות, ולכן שתי הגדרות
בשני הענפים חולקות שם מלא ונבדלות רק בשורה. וזה אינו מוגבל לענפים ואף
לא לשפה אחת: בתבנית, אלמנט עם id="main" וכלל CSS בשם div#main
בתוך בלוק <style> באותו קובץ מחזירים שתי שורות עם אותו שם בדיוק, ו-
symbol="div#main" מתאים לשתיהן. בכל המקרים שתיהן מוחזרות. הרשימה
ממוינת לפי שורת התחלה, ושובר-השוויון הוא השם, כדי שגבול העמוד יהיה יציב
בין קריאות ושתי השורות יחזרו.
השם מוחזר במלואו ולעולם אינו מקוצץ — מזהה שנחתך אינו מזהה, ו-symbol=
עם השם המלא חייב למצוא אותו.
הסיומות הנתמכות הן .py ו-.pyi לפייתון, .html, .htm,
.jinja, .jinja2 ו-.j2 לתבניות, .css ל-CSS, ו-.rst
לתיעוד — ללא תלות ברישיות. symbol= משווה ב-casefold, כך ש-STRASSE מוצא את
straße.
הערה
איפה הרשימה הזאת גרה בקוד, למי שמתחזק. מקור האמת הוא
outline._SCANNERS; מה שהסוכן קורא הוא תיאור הפרמטר outline
(``_OUTLINE_PARAM_DOC`` ב-mcp_server/server.py), ותיאור הפרמטר
symbol נושא את פסקת הסינון. שניהם ישבו עד כה בתיאור הכלי, ועברו
משם כשהתיאור הגיע ל-2,482 תווים ונחתך אצל הלקוח בדיוק בקטעים האלה.
``tests/test_mcp_server_build.py`` אוכף את שניהם במיקומם החדש, ובנוסף
שתיאור הכלי עדיין מפנה אליהם — סוכן שלא יֵדע שהפרמטרים נושאים
פירוט הוא אותו כשל במיקום חדש.
כל שורה שהמפה מחזירה היא שורה שאפשר להגיע אליה ב-lines=. זה נשמע
מובן מאליו אבל אינו: ast סופר שורות עם universal newlines, ואילו קריאת
הטווח מפצלת ב-split("\n") — כדי להתאים ל-lines_count שמשותף עם
הוובאפ, ראו קריאת טווח שורות. בקובץ שמכיל \r בודד השתיים נפרדות,
ולכן קובץ כזה נדחה במפורש במקום להחזיר מפה שמצביעה לשומקום. הערך היחיד של
המפה הוא שאפשר להמשיך ממנה לטווח.
מצב |
התשובה |
|---|---|
סיומת שאין לה סורק |
|
סיומת שאין לה סורק, בקובץ ש- |
אותה תשובה, ועוד הפניה: |
תחביר שבור, פייתון 2, קידוד פגום |
|
סופי שורות שאינם עקביים |
|
עמוד גדול מתקציב הפלט |
|
יותר סימבולים ממה שהמפה ממפה |
|
הפניה ל-``codekeeper_docs_get_section``, רק כשהוא באמת מגיש את הקובץ. על Markdown אין מפה, בזמן ש-codekeeper_docs_get_section כבר מחזיר את עץ הכותרות של אותו קובץ — ולכן no_outline עם reason: "unsupported_language" נושא הפניה אליו, כשהוא מגיש את הקובץ. השמות מיושרים עם item_too_large של codekeeper_read_batch: read_with הוא שם הכלי, ו-read_with_arguments הם הארגומנטים של הקריאה אליו. ההחלטה אם להפנות נגזרת מהשער של הכלי ההוא עצמו (section_read_arguments ב-mcp_server/docs_handlers.py, שקורא ל-resolve_docs_target), ולא מרשימה שנייה: ב-CodeBot הכלי מגיש רק docs/*.rst, ולכן על docs/*.md אין הפניה — הפניה קבועה לכל .md הייתה שולחת את הסוכן לכלי שיחזיר גם הוא ”לא נמצא“. יש הפניה רק כשהשער מחזיר בדיוק את הריפו והנתיב שהתבקשו, כי השער משלים סיומת לנתיב עם סיומת לא מוכרת (hook.sh היה נהיה hook.sh.md, קובץ אחר). ref נכנס להפניה רק כשהועבר, וכמו שהוא: בלעדיו ההפניה הייתה קוראת את הענף הראשי כשה-outline התבקש על ענף אחר. no_outline נשאר status, והתשובה נשארת ok: true — ההפניה היא שדה נוסף.
חשוב
no_outline הוא status ולא error, בדיוק כמו binary:
הקריאה הצליחה, פשוט אין תוכן מהסוג שביקשו. מי שבודק רק אם נזרקה
חריגה יראה קובץ שבור כקובץ בלי סימבולים — לכן חובה לבדוק את status.
HTML ו-Jinja
השמות שטוחים, לא מנוקדים. ב-HTML אין מרחבי שמות, ו-div בעומק
שתים-עשרה אינו שם משמעותי. התחילית נוספת רק כשיש עוגן אמיתי:
מה שבתבנית |
השם במפה |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
חשוב
הוספת id לבלוק <script> קיים משנה את שמן של כל
הפונקציות שבתוכו. זו התנהגות ולא באג — היא זהה לעטיפת פונקציות
במחלקה בפייתון, ששם גם היא משנה את שמות המתודות. מי שנשען על
symbol= עם שם פונקציה יצטרך את התחילית.
הפונקציות בתוך בלוק סקריפט הן העיקר. בלוק בן מאות שורות שמוחזר
כסימבול אחד נותן גבול ולא ניווט. ב-webapp/templates/base.html הבלוק
הגדול ביותר הוא מעל שבע-מאות שורות, והוא מפורק לעשרות הגדרות.
בלוק <script> נסרק פנימה רק כשהוא באמת JavaScript — כלומר בלי
type, או עם module, או עם MIME type של JavaScript לפי
mimesniff.spec.whatwg.org. <script type="application/json"> הוא
data block: גבולותיו מוחזרים, תוכנו לא נסרק.
הערה
הסורק אינו פרסר עץ, ובכוונה. תבנית Jinja אינה HTML תקין: תגית
שנפתחת בענף אחד של {% if %} ונסגרת בענף אחר היא הכתיב הרגיל כאן.
פרסר DOM מאזן את זה בשקט ומחזיר שורות שאינן במקום שבו הטקסט יושב.
תגית שלא נסגרה מדווחת עד סוף הקובץ — זו התשובה הכנה, כי היא באמת לא
נסגרה.
וגם בלוק <style> הוא מפה ולא רק גבול. הבלוקים שבתוך התבניות
נושאים נפח CSS שדומה לזה של קובצי ה-.css עצמם, ובהם מקור האמת של
טוקני הערכה: ה-:root הגלובלי ובלוקי :root[data-theme="..."]
יושבים ב-webapp/templates/base.html ולא בקובץ CSS, ראו
מערכת ערכות הנושא והטוקנים החדשה. הבלוק נסרק בסורק ה-CSS, והשמות נבנים לפי
אותו מודל תחילית: בלוק בלי id ← סלקטורים שטוחים; בלוק עם id ←
style#user-custom-theme.:root[data-theme="custom"].
בלוק <style> נסרק פנימה רק כשהוא באמת CSS — כלומר בלי type, או עם
text/css בדיוק. type="text/plain" אינו יוצר גיליון סגנון בדפדפן,
וגם type="text/css; charset=utf-8" אינו — פרמטרים נדחים, כמו בכלל
ה-essence של <script>.
CSS
הסימבול הוא בלוק הסלקטור, והעיקר הוא ה-at-rules. שם קבורים המובייל
וה-RTL, ושם באמת מחפשים: @media (max-width: 768px) מקבל טווח שורות
משלו, ואחריו אפשר להמשיך ישר ל-lines=.
מה שבקובץ |
השם במפה |
|---|---|
|
|
סלקטורים מרובים על כמה שורות |
שם אחד, מנורמל לרווח יחיד: |
|
|
|
|
|
|
|
אינו סימבול — at-rule בלי בלוק אין לה טווח |
הבלוקים שבתוך @media הם סימבולים שטוחים בפני עצמם, בלי
תחילית. ההשתייכות נקראת מטווח השורות, בדיוק כמו ב-HTML.
ההערה שמעל הסלקטור אינה חלק מהשם, ושורת ההתחלה היא של הסלקטור ולא של
ההערה. הערה בתוך סלקטור אינה שוברת אותו: .a /* x */ .b הוא סלקטור
אחד.
חשוב
בקובץ ממוזער כל הסימבולים מצביעים לאותה שורה, וזו התנהגות ולא באג.
קובץ שכל תוכנו בשורה אחת מחזיר מפה שכל רשומה בה אומרת אותה שורה —
כי שם הבלוקים באמת יושבים. ה-start וה-end נכונים, והחוזה נשמר:
אפשר להמשיך מהם ל-lines=. מפה כזאת פשוט אינה מוסיפה ניווט, ומי
שמחפש בלוק בקובץ ממוזער ימשיך ב-symbol= ולא בשורות.
RST
הסימבול הוא כותרת, והמפה היא תוכן עניינים עם טווחי שורות. זה בדיוק המקום
שבו חיפוש טקסט מאכזב: שאילתה כמו רמת לוג תמצא אזכורים בפרוזה ולא את
הסעיף שמגדיר אותה, ומפה שאומרת באילו שורות הסעיף יושב מאפשרת להמשיך ישר
ל-lines=.
ומה שמעגן את הכללים שלמטה: זיהוי הכותרות מיושר לפארסר של docutils 0.23, ונבדק מול כל קובצי ה-RST של התיעוד הזה — אותן רמות ואותה ספירה בכל קובץ — ומול טבלאות של צורות כותרת סינתטיות. זה נאמר כאן כי הכללים נראים כמו החלטות שלנו, והם אינם: הם מה ש-Sphinx יעשה עם אותו קובץ.
חשוב
אין משמעות מובנית לתו הפיסוק. === אינו ”רמה 1“. ההיררכיה נקבעת
לפי סדר הופעת התווים בכל קובץ בנפרד: התו הראשון שנתקלים בו הוא רמה
1, התו החדש הבא הוא רמה 2, וכן הלאה. לכן אותו קובץ יכול להשתמש ב-^
לרמה 3 ואחר ב-~, ושניהם תקינים.
השמות מנוקדים לפי ההיררכיה, כמו בפייתון: התקנה.דרישות.פייתון.
כותרת המסמך הופכת לתחילית של כל שם, וזה מה שמפריד שתי כותרות זהות תחת
אבות שונים.
המבנה |
השם |
|---|---|
כותרת עם קו תחתון |
הכותרת, מנוקדת לפי האבות שלה |
כותרת עם קו מעליה ומתחתיה |
אותו דבר, והטווח מתחיל בשורת הקו העליון |
|
|
יעדי .. _label: הם סימבולים נפרדים, ואלה היעדים שאליהם
:ref: מצביע. כשקישור פנימי בתיעוד נשבר, זה מה שמחפשים —
symbol="_" מגיע אל כולם. יעד חיצוני (.. _site: http://x)
אינו יעד של :ref: ואינו במפה, וכך גם יעד אנונימי (.. __:) שאין לו
שם להפנות אליו.
אזהרה
ו-``symbol=“_“`` אינו מחזיר את התוויות ואותן בלבד. הסינון הוא
בהכלה בכל השפות, כפי שכתוב בפסקת symbol= למעלה, ולכן כל כותרת
שיש בה קו תחתון חוזרת גם היא. בעמודי ה-API של התיעוד הזה זה המצב
הרגיל ולא החריג, כי הכותרת שם נושאת את ה-escape של RST. תווית מזוהה
בכך ש-start שווה ל-end; לכותרת יש טווח.
הערה
הנקודה בשם אינה מפריד בלעדי, וזו התנהגות מוצהרת. כותרת RST יכולה
להכיל נקודה בעצמה — עמודי ה-API בתיעוד הזה עושים זאת, עם כותרות כמו
chatops.permissions module — ולכן a.b.c יכול להיות ”c תחת
a.b“ או ”b.c תחת a“. השם הוא תווית ל-``symbol=`` ולא
מבנה שנפרס חזרה, וההשתייכות נקראת מטווח השורות. זו אותה מוסכמה שכבר
חלה על style#theme..foo בתבניות.
הערה
טקסט הכותרת הוא המקור ולא הרינדור, ויש לזה מחיר ב-``symbol=``.
עמוד autodoc נושא כותרת כמו services.backup\_service module —
עם הלוכסן, כי כך היא כתובה בקובץ — ולכן symbol="backup_service"
אינו מוצא אותו, וצריך את ה-escape בשאילתה. זה נאמר כאן במפורש
כי אחרת התוצאה הריקה נראית כמו עמוד בלי אאוטליין.
כותרת שנכתבה
`` code_snippets `` מוחזרת כך, ולא כ-code_snippets. הסיבה:
start מצביע לשורה בקובץ המקור, ושם מנורמל לא היה תואם לשורה שאליה
הוא מצביע.
אזהרה
מחלקה אחת מוצהרת שאינה ממודלת: בלוק literal מצוטט. תקן RST מתיר בלוק שאינו מוזח ששורותיו נפתחות בתו פיסוק, ו-Sphinx קורא את כולו כטקסט מילולי. הסורק כאן אינו מזהה את הצורה הזאת, ולכן שורת פיסוק בתוכה עלולה להיראות לו כקו של כותרת — כלומר שורה במפה שאינה כותרת בעמוד המרונדר. בקובצי התיעוד האלה אין לצורה אף מופע, והיא כתובה כאן כדי שלא תיראה כבאג במי שייפגש בה בריפו אחר.
וקובץ RST בלי אף כותרת אינו כשל — הוא מחזיר מפה ריקה עם status:
outline, כמו קובץ CSS בלי כללים. no_outline שמור לסיומת שאין לה
סורק ולתקרת הסימבולים.
קריאת טווח שורות
codekeeper_get_file ו-codekeeper_get_repo_file מקבלים lines=[start, end]
— אותו שם, אותה צורה ואותה סמנטיקה בשני הכלים, כי אסימטריה בין הקבצים
השמורים לבין הריפואים היא בדיוק סוג הפער שמייצר באגים. 1-indexed, כולל את שני
הקצוות. בלי הפרמטר מוחזר הקובץ המלא, כמו תמיד.
בתשובה של קריאת טווח נוסף בלוק range, זהה בשני הכלים:
{"range": {"start": 3, "end": 40, "total_lines": 812, "truncated": false}}
total_lines קיים כדי שהקורא ידע מה לא קיבל.
הערה
הספירה היא len(content.split("\n")), כלומר קובץ שנגמר בשורה ריקה
נספר כשורה נוספת: ל-"a\nb\nc\n" יש ארבע שורות, והרביעית ריקה.
זו אותה קונבנציה שלפיה נספרים השדות שיושבים לצידו באותה תשובה —
file.lines ב-codekeeper_get_repo_file ו-file.lines_count
בקובץ שמור — כך שאותה תשובה לא נושאת שני מספרים סותרים. כל שורה
ש-total_lines מצהיר עליה גם ניתנת לבקשה בפועל.
entries.lines ב-codekeeper_list_repo_tree מגיע מהאינדקסר, שסופר
ב-splitlines() ולכן אינו סופר את השורה הריקה האחרונה. אלה שתי
תשובות נפרדות ולא סתירה בתוך אחת, אבל כדאי לדעת לפני שמשווים ביניהן.
הערה
הטקסט מוחזר בית-בית כפי שהוא בקובץ. בקובץ עם סופי שורה של חלונות
(CRLF) כל שורה מגיעה עם \r בסופה, וזו התנהגות מכוונת ולא באג: היא
מה שמאפשר להרכיב טווחים. הטווח המלא מחזיר את הקובץ במדויק, וחיבור שני
טווחים סמוכים ב-\n משחזר אותו — מי שקורא קובץ בחלקים מקבל בסוף את
המקור, ולא גרסה שהשתנתה בשקט. קיצוץ ה-\r היה שובר את שתי התכונות
האלה, וגם מחזיר ספירה שסותרת את file.lines שיושב באותה תשובה.
הערה
``file.encoding`` אומר פחות ממה שהוא נראה, וכדאי לדעת מה. הפענוח
מנסה קודם חתימת BOM ואחר כך רשימה, ב-services/git_mirror_service.py:
utf-8— הקובץ פוענח כ-UTF-8. זה הרוב המוחלט.utf-8-sig— היה BOM בתחילת הקובץ. הוא מוסר מ-``content``, וזה החריג היחיד ל“בית-בית כפי שהוא בקובץ“ שלמעלה.sizeמגיע מ-git וסופר את הבלוב, כלומר כולל את שלושת הבתים שלא תקבלו, ו-linesנספר על הטקסט אחרי ההסרה.latin-1— נפילה-לאחור ולא זיהוי.latin-1מפענח כל רצף בתים ואינו נכשל לעולם, ולכן הוא תופס כל מה ש-UTF-8 דחה. נמדד: קובץ עברי שנכתב ב-cp1255חוזר עם התוויתlatin-1ועם תוכן משובש —שלוםהופך ל-ùìåí. התווית נראית בטוחה, והתוכן אינו.
המשמעות המעשית: utf-8 ו-utf-8-sig הם זיהוי, ו-latin-1
הוא ”שום דבר אחר לא עבד“. קובץ שחוזר עם התווית הזאת ראוי לחשד לפני
שנשענים על תוכנו — ובעברית זה המקרה השכיח, כי cp1255 אינו נגיש
ברשימה אחרי latin-1.
אותה תווית, באותה משמעות, חוזרת כ-encoding בתשובה של
codekeeper_docs_get_section עם whole_document=true
(איזה קובץ docs_get_section קורא, ובאיזה ריפו). תשובת סעיף אינה נושאת אותה.
קלט |
התנהגות |
|---|---|
|
מקוצץ לסוף הקובץ, ו- |
הטווח אינו נכנס ב- |
נגמר מוקדם, על גבול שורה: |
|
|
|
|
ערך שאינו מספר שלם (מחרוזת, שבר, בוליאני) |
נדחה כבר בסכימה, לפני שהכלי רץ |
הערה
הדחייה של בוליאני אינה קוסמטית. list[int] רגיל מקבל [true, 5]
וממיר אותו ל-[1, 5], כלומר קריאה שגויה הייתה מחזירה בשקט את שורות 1‑5.
ההמרה קורית בגבול הסכימה, לפני שקוד הכלי רץ, ולכן ההגנה יושבת בהצהרת הטיפוס
(strict) ולא בוולידציה שבתוך הכלי.
אזהרה
ל-codekeeper_get_repo_file יש שתי תקרות, לפי מצב הקריאה: 500KB
לקובץ מלא, ו-10MB כשמועבר lines. הבלוב עדיין נקרא ומפוענח במלואו
לפני החיתוך, ולכן התקרה הוחלפה ולא בוטלה — קובץ מעל 10MB מוחזר
כ-status: "too_large" גם עם טווח, והשדה max בתשובה אומר לפי איזו
תקרה נשפט. ה-500KB ממשיכה לחול על הוובאפ ועל כל קריאה בלי lines.
הטווח אינו דרך לעקוף את התקרה.
כמה מופעים יש באמת (total מול total_at_least)
codekeeper_search_repo מחזיר שני מספרים שקל להתבלבל ביניהם, ולכן הם
נאמרים כאן במפורש:
count— כמה פגיעות חזרו בתשובה הזו. חסום ב-max_results.total— כמה פגיעות יש בריפו לשאילתה הזו.
עד ספטמבר 2026 ``total`` היה שווה ל-``count`` תמיד, כי אחרי שנאספו
max_results תוצאות התהליך נהרג ואיש לא ספר את השאר. זו הייתה מלכודת
אמיתית: באותו שרת, query= של codekeeper_get_file מחזיר בשדה בעל
אותו שם את הסך האמיתי — וסוכן שלמד את המשמעות בכלי אחד ייחס אותה לשני.
החוזה היום
חשוב
אם ``total`` קיים — הוא מדויק. אין דגל שצריך לקרוא כדי לדעת אם לסמוך עליו, כי שדה שמשמעותו משתנה לפי דגל הוא בדיוק המלכודת שתוארה למעלה.
כשהספירה הסתיימה, התשובה נושאת total עם המספר המדויק — גם כשהוא גדול
בהרבה מ-count. כשהספירה נקטעה (תקרה או timeout), אין total
כלל; במקומו מגיע total_at_least — חסם תחתון — ועוד truncation_reason
שאומר מה עצר אותה. שני השדות לעולם אינם מופיעים יחד.
יש מקרה שלישי, נדיר: שורה שנחסמה בשכבה האחרונה של מדיניות הסודות,
זו שרצה על התוצאות. אם היא הסירה משהו, פירוש הדבר שהמנוע סרק וספר קובץ
שאנחנו מסרבים להגיש — הסך שלו כולל מופעים שאיש לא יקבל, והצגתו כמדויק
הייתה גם טענה שגויה וגם אמירה בקול כמה מופעים יש בתוך קובץ חסום. גם שם
חוזר total_at_least, עם truncation_reason: "policy_filtered".
ורביעי: המנוע נפל באמצע, אחרי שכבר אסף תוצאות — למשל אובייקט פגום
במראה. מה שנאסף מוגש, עם truncation_reason: "search_failed" ובלי
total; ואם sync רץ באותו רגע, התשובה היא sync_in_progress כמו
בשאר המסלולים, כי עמוד חלקי מעץ שזז מתחת לרגליים גרוע מ“נסה שוב“. ואובייקט
חסר — ש-git grep מדלג עליו ומתלונן ב-stderr בזמן שהוא יוצא ב-0 —
נתפס דרך ה-stderr ומוריד את התשובה ל-total_at_least עם
count_failed: קוד יציאה תקין אינו הוכחה לסריקה שלמה.
זה אותו קו שכבר קיים באאוטליין: כש-too_many_symbols עוצר את הסריקה,
אין total, כי לא ידוע כמה יש. השמטה בשקט אינה תשובה, ומספר שנראה
מדויק ואינו — גרוע ממנה.
{"ok": true, "repo": "CodeBot", "query": "get_db(",
"count": 2, "total": 65, "truncated": true,
"truncation_reason": "max_results",
"results": [{"path": "webapp/app.py", "line": 118, "snippet": "..."}]}
איך זה נספר
הספירה היא מעבר שני של git grep, נפרד ממעבר התוצאות, והיא אינה
שומרת ולו שורה אחת בזיכרון: git grep -c סופר בעצמו ומדפיס רשומה אחת
לכל קובץ תואם.
למה שני מעברים ולא אחד. מעבר התוצאות מגביל את ההתאמות לכל קובץ
(git grep -m), וזה מה שמפזר את התוצאות על פני קבצים במקום למלא את
כולן מקובץ אחד. אותה מגבלה חלה גם על הספירה — קובץ עם חמש התאמות מדווח
שתיים תחת -m 2 — ולכן מעבר אחד אינו יכול לתת גם תוצאות מפוזרות וגם
סך מדויק. המעבר השני רץ בלי המגבלה, ו**רק כשהראשון אינו יכול לענות**:
חיפוש צר שהסתיים מעצמו ושבו שום קובץ לא נגע במכסה כבר יודע את הסך.
שני המעברים רצים על אותו קומיט ולא על אותו שם ענף, כי ביניהם ה-
autosync יכול למשוך עדכון — ואז total היה מתאר עץ אחר מזה שהתוצאות
הגיעו ממנו.
מה לא נכנס לספירה
קבצים חסומים במדיניות הסודות. ההחרגה נכנסת ל-
git grepעצמו, ונגזרת מאותה רשימת תבניות שחוסמת קריאה — לא מרשימה שנייה. קובץ חסום אינו נסרק, אינו מוחזר ואינו נספר. ראו מדיניות סינון סודות.קוד חיצוני וקבצים מקומפלים —
node_modulesבכל עומק, ו-*.bundle.js,*.min.js,*.min.css,*.map— כברירת מחדל. הרשימה ב-VENDORED_PATH_GLOBSב-services/git_mirror_service.py, ו-include_vendored=trueמחזיר את כולם יחד לחיפוש ולספירה. קבצים כאלה ממשיכים להיקרא כרגיל דרךcodekeeper_get_repo_file— ההחרגה היא של החיפוש בלבד. מה שקובע כאן הוא שורות תואמות ולא קבצים: באנדל אחד יכול להחזיק את רוב המופעים של מילה שגרתית כמוfunction.שורות הקשר.
context_linesאינו משנה את הספירה: שורת הקשר אינה התאמה.
אזהרה
אפס תוצאות אינו ”המחרוזת לא קיימת בריפו“. ברירת המחדל מחריגה קוד
חיצוני, ולכן מחרוזת שיושבת רק תחת node_modules תחזיר אפס. מי
שמחפש אותה במפורש — include_vendored=true.
רישיות
ברירת המחדל אינה רגישה לרישיות, כלומר Config מתאים גם ל-config
ול-CONFIG. זו ההתנהגות שהייתה כאן מאז ומתמיד (git grep -i), והיא
לא זזה.
case_sensitive=true מבקש התאמה מדויקת. הוא חל גם עם regex=true
(שני מתגים נפרדים ב-git grep, ולכן נבדק ולא הונח), וגם על הספירה:
מופע שהדגל מוציא מהתוצאות אינו נספר ב-total.
הערה
עד ספטמבר 2026 לא הייתה דרך לבקש זאת. הפרמטר היה קיים לכל אורך המנוע,
אבל שכבת ה-MCP פשוט לא העבירה אותו — ולכן git grep רץ תמיד עם
-i. ושתי השכבות שמתחת מצהירות ברירות מחדל הפוכות (False
ב-RepoSearchService.search, True ב-search_with_git_grep),
ולכן כל קורא בשרשרת מעביר את הערך במפורש ואינו נשען על אף אחת מהן.
סינון לפי נתיב (file_pattern)
file_pattern הוא pathspec אחד של גיט. הערך עובר כמו שהוא ל-git grep (search_with_git_grep ב-services/git_mirror_service.py), ולכן מי שקובע מה הוא תופס הוא גיט ולא הקוד שלנו. עד אוקטובר 2026 לא היה לפרמטר תיאור בסכמה, וסוכן שרצה להחריג את assets/vendor ביקש כלי חדש בלי לדעת שהיכולת כבר קיימת. התיאור של הפרמטר נוקב היום בכל הצורות שבטבלה, ותיאור הכלי מפנה ל-:(exclude) — כי מי שלא יודע שאפשר להחריג תיקייה לא יחפש את זה בתיאור של הפרמטר.
תבנית |
מה היא תופסת |
|---|---|
|
כל מה שתחת התיקייה, בכל עומק |
|
קבצי |
|
קבצי |
|
מחמיץ את הקבצים שיושבים ישירות בתיקייה, בלי שגיאה. |
|
הכול חוץ מהתיקייה |
|
כמו |
מהשורש.
deepאינו תופס אתdocs/deep; צריך את הנתיב המלא.רגיש לרישיות, גם כשהשאילתה לא. הכלי רץ כברירת מחדל עם
git grep -i, והדגל הזה חל על התוכן ולא על הנתיב — נמדד:*.htmlאינו תופס אתtop.HTMLגם תחת-i.case_sensitiveאינו נוגע בתבנית;:(icase)כן.תבנית אחת לקריאה. רשימה שנשלחת כמחרוזת נקראת כנתיב אחד, ואינה תופסת כלום.
רק מצמצם. ההחרגות של הסודות ושל הקוד החיצוני נכנסות לאותה פקודה כ-pathspec שלילי, וגיט מריץ כל נתיב שהתאים דרך כל ההחרגות. לכן תבנית שמתאימה ל-
config/.envאו ל-static/lib.min.jsמחזירה אפס, ואפס כזה אינו ”אין קובץ כזה“.include_vendored=trueמחזיר את הקוד החיצוני; סוד נשאר חסום תמיד — ראו מדיניות סינון סודות.
המקור, ומה ששומר עליו. ערך pathspec ב-Documentation/glossary-content.txt של git v2.43.0: ”«*« and »?« can match directory separators“, ועל exclude: ”After a path matches any non-exclude pathspec, it will be run through all exclude pathspecs“. כל צורה בטבלה נמדדה על git 2.43.0 ומקובעת, עם התוצאה שנמדדה לה, ב-tests/test_mcp_search_total.py (הסעיף על file_pattern) — כך ששדרוג גיט שמשנה צורה יפיל את ה-CI, ולא ימשיך להיות מובטח לסוכנים בתיאור. אותו קובץ בודק שהתיאור, כפי שהלקוח מקבל אותו ב-tools/list, נוקב בכל צורה. איזו גרסת גיט רצה בשרת החי לא נבדק; התיקייה, *.html ו-:(exclude) נמדדו שם ישירות, על Markdown-Docs.
איך מחפשים קובץ ופתק — codekeeper_search_code ו-codekeeper_search_notes
הערה
שני החיפושים אינם עובדים אותו דבר, ולא במקרה.
codekeeper_search_code הוא $text של מונגו מול אינדקס טקסט
(search_text_idx ב-code_snippets) שמכסה file_name,
description, tags ו-code. מונגו מפרק שם למילים ומבצע
stemming, ולכן ההתאמה היא למילים שלמות ולא לתת-מחרוזות: חיפוש
auth לא יתפוס authenticate.
לאיתור מחרוזת חלקית בקובץ שמור — codekeeper_get_file עם query.
זו סריקת מחרוזת על הקובץ עצמו — בלי stemming, בלי הרשאת אדמין, ובלי
למשוך את התוכן: מה שחוזר הוא המופעים. ראו חיפוש בתוך קובץ שמור (query).
ההפרדה בין השניים היא בשאלה שהם עונים עליה: codekeeper_search_code
עונה על איזה קובץ, ו-query עונה על איפה בתוך הקובץ, ולכן
הצירוף הרגיל הוא קודם האחד ואז השני.
בריפו משוקף המקבילה היא codekeeper_search_repo, והוא כלי אדמין.
codekeeper_search_notes הוא רג’קס לא-מעוגן, ולכן כן תופס חלק ממילה. כברירת מחדל הוא שואל על title בלבד. (עד אוקטובר 2026 נכתב כאן שהוא נשען על user_title_idx; explain על החיפוש האמיתי, הממוין לפי updated_at, הראה תוכנית שקוראת לפי סדר העדכון — ראו את האינדקס בהמשך.)
search_content=true מוסיף פרדיקט על גוף הפתק. הדגל קיים בגלל פתק
בלי שם: רוב הפתקים בפועל נכתבים בלי כותרת, ולכן היו בלתי-נראים
לחיפוש לחלוטין — לא ”קשים למציאה“, אלא בלתי-ניתנים למציאה. הוא כבוי
כברירת מחדל כי אין ולא יהיה עליו אינדקס: חיפוש בגוף היה מחייב אינדקס
טקסט שני על sticky_notes, ומונגו מתיר אינדקס טקסט אחד לכל אוסף
— החלטה חד-כיוונית שלא נשרפה כאן. מה שכן מגן הוא אינדקס שנותן יחד את user_id ואת סדר המיון, ולכן הסריקה חסומה לפתקים של המשתמש ונעצרת בתקרה כשיש מספיק התאמות. איזה אינדקס, ולמה זה נמדד ולא הוסק — חיפוש בפתקים: למה רג’קס ולא $text. עד אוקטובר 2026 נכתב כאן ש-user_id כפרדיקט ראשון הוא מה שחוסם את הסריקה; explain הראה שבלי אינדקס כזה התוכנית הזוכה סורקת את הפתקים של כל המשתמשים.
הארכיון מוסתר, אלא אם ביקשו. פתק שהמשתמש העביר לארכיון עזב את הלוח או הקובץ שלו, ולכן הכלי אינו מחזיר אותו בלי include_archived=true. ההסתרה יושבת ב-note_search_filter, שהחיפוש בוובאפ עובר בו גם הוא — שם זו התיבה ”כלול ארכיון“ — ולכן שני החיפושים מחזירים אותו דבר. התשובה אומרת אם הארכיון נבדק (searched_archive), כמו searched_content לגוף: סוכן שלא מצא פתק שהוא מכיר יודע לנסות שוב עם הפרמטר, ולא מסיק שהפתק נמחק. פגיעה מהארכיון היא target: archive בלי שדות זיהוי, ואין לה כלי רשימה — codekeeper_list_board_notes אינו רואה אותה, כי ההעברה מוחקת את board_id — ולכן קוראים אותה ב-codekeeper_get_note. pydantic מפענח את הפרמטר לפי הסכימה ("false" הוא False), וה-handler מעביר הלאה רק True, כי הוא נקרא גם ישירות.
הפגיעה עדיין לא נושאת תוכן. הפרויקציה מנתקת את עלות ההעברה מגודל
הפתק (עד MAX_NOTE_CHARS תווים), ולכן גם פגיעת-תוכן מחזירה ”איפה“
ולא ”מה“; את הפתק קוראים ב-codekeeper_get_note לפי המזהה שבפגיעה —
לא בכלי הרשימה, שמחזיר את המשטח כולו כדי להגיע לפתק אחד (ראו
קריאת פתק בודד — codekeeper_get_note). הסינון עצמו כן קורא את הגוף בשרת — זו העלות
שהדגל קונה.
הגוף והמחט — כמו שהוקלדו. גוף הפתק נשמר כמו שנשלח (קריאת פתק בודד — codekeeper_get_note), ומחט התוכן היא השאילתה אחרי strip בלבד, בלי פענוח ובלי קיפול שורות. לכן & מוצא את & שמשתמש הקליד, ולא את &. מחט שיש בה NUL נענית invalid_query, כי מונגו דוחה $regex כזה בחריגה: הבדיקה היא note_search_needle_error ב-sticky_notes_target.py, אותה בדיקה של החיפוש בוובאפ. עד אוקטובר 2026 גם הגוף וגם המחט עברו html.unescape, ו-NUL נמחק מהמחט בשקט.
חיפוש בתוך קובץ שמור (query)
codekeeper_get_file עם query="..." מחזיר את המופעים של המחרוזת
בקובץ במקום את התוכן — אותה תבנית שבה outline=true מחזיר מפה במקום תוכן
ב-codekeeper_get_repo_file. הבעיה שזה פותר: סוכן שרוצה שדה אחד מתוך קובץ
JSON גדול, או כלל אחד מתוך מסמך, מושך היום את הקובץ כולו — או מנחש טווח.
{"found": true,
"status": "query",
"file": {"id": "6a98...", "file_name": "CobaltNext.json", "version": 2,
"lines_count": 812, "file_size": 31174, "language": "json"},
"query": "timeout",
"count": 2, "total": 2, "truncated": false,
"results": [{"line": 41, "snippet": "\"timeout\": 30,"},
{"line": 207, "snippet": "\"timeout_ms\": 4500,"}]}
אותם שמות שדות כמו ב-codekeeper_search_repo: count, total,
truncated ו-results, ולכל פגיעה line ו-snippet. context_lines=N
מוסיף context_before ו-context_after בדיוק כמו שם, ושני המפתחות
מתווספים רק כשביקשו אותם. אין כאן אוצר מילים שני לאותו דבר.
ומה שאינו זהה, כדי שלא יוסק מהשורה שמעל. אין path, כי מדובר בקובץ
אחד. ו-total כאן קיים תמיד ותמיד מדויק — סריקה של קובץ בודד
מסתיימת תמיד. בריפו שלם יש לספירה תקרה ו-timeout, ולכן שם היא יכולה
להיקטע, ואז חוזרים total_at_least ו-truncation_reason במקום
total. שני השדות האלה אינם קיימים במסלול הזה. ראו
כמה מופעים יש באמת (total מול total_at_least).
file נושא מטא-דאטה בלבד. התוכן יורד — זה כל הרעיון — דרך אותה רשימת
שדות כבדים שמסירה אותו בכל מסלול רשימה או חיפוש אחר בשרת הזה.
ההמשך הוא lines. כל line שחוזר הוא שורה שאפשר לבקש בפועל:
lines=[line - 20, line + 20] לקטע סביבו, או lines=[line, line]
לשורה עצמה. זה מובטח מפני שהסריקה מפצלת שורות בדיוק כמו קריאת הטווח
(split("\n")), ראו קריאת טווח שורות.
מה ההתאמה עושה, ומה לא
סריקת מחרוזת, ותו לא. אין stemming, אין רג’קס ואין גבולות מילה — תו
מיוחד בשאילתה הוא תו. אותו חוזה בדיוק חל על codekeeper_search_repo,
שמתאים מילולית כברירת מחדל ועובר ל-git grep -E רק כש-regex=true
נמסר במפורש. מה שנשאר שונה הוא codekeeper_search_code: הוא $text
של מונגו, ולכן מתאים למילים שלמות ולא למחרוזת.
ההתאמה אינה רגישה לרישיות, כמו ברירת המחדל של
codekeeper_search_repo. ההשוואה היא ב-casefold, אותה בחירה כמו
symbol= באאוטליין. מה ש**כן** שונה: לחיפוש בריפו יש
case_sensitive=true שמבקש התאמה מדויקת, ולמסלול הזה אין — כאן אין
פרמטר כזה, וההתאמה תמיד חסרת רישיות.
השאילתה אינה מקוצצת, כאן ובריפו כאחד: חיפוש של הזחה (" return")
הוא שימוש אמיתי, וקיצוץ היה משנה בשקט את מה שביקשו. הקיצוץ משמש רק כדי
להכריע אם השאילתה ריקה.
שורה אחת לכל פגיעה. שני מופעים באותה שורה הם רשומה אחת, כמו ב-git grep.
מצבי הקצה
קלט |
התנהגות |
|---|---|
|
|
|
|
אפס מופעים |
הצלחה, עם |
|
|
|
מתקבל, ומחזיר מופעים. |
יותר מופעים מהתקרה |
|
|
|
|
נצמדים, ואינם נדחים — אותה מדיניות מוצהרת של שאר פרמטרי העימוד
בשרת הזה, ראו הקבועים, התקרות ואוצר המילים של התשובה. ההצמדה חלה רק כשהועבר |
חשוב
התקרה על טקסט הרשומה נמדדת בבתים, לא בתווים. המספר נלקח מ-
codekeeper_search_repo, שחותך ב-500; מה שהוחלף היא היחידה. תו עברי
הוא שני בתים ותו CJK שלושה, ולכן חסם בתווים אינו אומר דבר על גודל
התשובה בפועל — וזה כבר נפל כאן פעם אחת, כשחסם של 200 תווים לרשומה הניב
עמוד של 323,000 בתים מול תקציב של 256,000.
החיתוך נוחת תמיד על גבול תו. encode()[:n] לבדו מחזיר רצף בתים
פגום שאינו זורק במקום שגרם לו אלא אצל מי שמנסה לפענח אותו, ולכן הפענוח
נעשה מיד ומה שיוצא הוא תמיד מחרוזת שלמה.
תקציב הבתים (OUTPUT_BYTE_BUDGET) נמדד על התשובה כפי שהיא נשלחת. קודם נשמר המקום למעטפת במקרה הגרוע שלה — file, query, count בתקרה, total בכל שורות הקובץ, truncated וסיבה — ורק מה שנשאר הולך לרשומות, כל אחת כפי שהיא יושבת ברשימה, עם ההזחה שה-SDK מוסיף. עד #3474 נמדדו רק הרשומות, ב-JSON דחוס, מול התקציב כולו, ותשובה שנמדדה בתוכו יצאה 259,168 בתים. כש-truncated דלוק, truncation_reason אומר למה: max_results — התקרה שביקשו, או byte_budget — התקציב עצר לפניה. וכשגם מופע אחד אינו נכנס — מה שקורה רק כשהמטא-דאטה של הקובץ עצמה גדולה — התשובה היא answer_too_large.
קריאה לפי סעיף בקובץ Markdown שמור (toc ו-section)
codekeeper_get_file קורא קובץ Markdown שמור לפי סעיף, בשני מצבים שיושבים לצד lines ו-query: toc=true מחזיר את מפת הכותרות במקום התוכן, ו-section="..." מחזיר סעיף אחד — עם הכותרות שמעליו (breadcrumb), טווח השורות שלו, תת-הסעיפים שלו והשכנים שלו. הבעיה שזה פותר: מסמך שמור ארוך — מסמך העברה, תוכנית, רשימת כללים — נקרא עד היום כולו, או בטווח שנוחש. עכשיו קוראים את המפה, בוחרים כותרת, וקוראים רק אותה.
{"found": true, "status": "toc", "ok": true,
"file": {"id": "6a9f0c2e1b7d4e5f6a7b8c9d", "file_name": "Handoff.md",
"version": 7, "lines_count": 16, "file_size": 195, "language": "markdown"},
"includes": [], "mode": "toc",
"toc": [{"title": "מדריך העברה", "level": 1, "breadcrumb": ["מדריך העברה"],
"line_range": [1, 16], "approx_bytes": 195},
{"title": "K11. חריגה נבלעת", "level": 2,
"breadcrumb": ["מדריך העברה", "K11. חריגה נבלעת"],
"line_range": [5, 12], "approx_bytes": 110},
{"title": "דוגמה", "level": 3,
"breadcrumb": ["מדריך העברה", "K11. חריגה נבלעת", "דוגמה"],
"line_range": [9, 12], "approx_bytes": 37},
{"title": "K12. תקרה בלי סירוב", "level": 2,
"breadcrumb": ["מדריך העברה", "K12. תקרה בלי סירוב"],
"line_range": [13, 16], "approx_bytes": 46}],
"toc_truncated": false, "section_count": 4}
approx_bytes הוא גודל הסעיף בבתים, כדי שאפשר יהיה לבחור סעיף בלי לקרוא אותו. ואחרי המפה — section="K11":
{"found": true, "status": "section", "ok": true,
"file": {"id": "6a9f0c2e1b7d4e5f6a7b8c9d", "file_name": "Handoff.md",
"version": 7, "lines_count": 16, "file_size": 195, "language": "markdown"},
"includes": [], "mode": "section",
"section": "K11. חריגה נבלעת",
"breadcrumb": ["מדריך העברה", "K11. חריגה נבלעת"],
"level": 2, "line_range": [5, 12], "include_subsections": true, "offset": 0,
"content": "## K11. חריגה נבלעת\n\nמה קורה כשחריגה נבלעת.\n\n### דוגמה\n\nקוד לדוגמה.\n",
"truncated": false,
"subsections": [{"title": "דוגמה", "level": 3, "line_range": [9, 12]}],
"neighbors": {"prev": null,
"next": {"title": "K12. תקרה בלי סירוב", "level": 2, "line_range": [13, 16]}}}
(שתי הדוגמאות הן פלט אמיתי של הכלי על קובץ סינתטי, ו-file מקוצר בהן לשדות שרלוונטיים כאן.)
ובקובץ שכל פרק בו חוזר על אותן תת-כותרות — רק הפרקים. max_depth=2 עם toc=true משאיר את הכותרות שה-breadcrumb שלהן באורך 2 לכל היותר, ועל כל כותרת בגבול שהוסתר תחתיה משהו — hidden_subsections, כמה. על אותו קובץ (שוב פלט אמיתי, ו-file מקוצר):
{"found": true, "status": "toc", "ok": true,
"file": {"id": "6a9f0c2e1b7d4e5f6a7b8c9d", "file_name": "Handoff.md", "version": 7},
"includes": [], "mode": "toc",
"toc": [{"title": "מדריך העברה", "level": 1, "breadcrumb": ["מדריך העברה"],
"line_range": [1, 16], "approx_bytes": 195},
{"title": "K11. חריגה נבלעת", "level": 2,
"breadcrumb": ["מדריך העברה", "K11. חריגה נבלעת"],
"line_range": [5, 12], "approx_bytes": 110, "hidden_subsections": 1},
{"title": "K12. תקרה בלי סירוב", "level": 2,
"breadcrumb": ["מדריך העברה", "K12. תקרה בלי סירוב"],
"line_range": [13, 16], "approx_bytes": 46}],
"toc_truncated": false, "section_count": 4, "max_depth": 2}
toc_truncated כבוי, כי המפה לא נחתכה — היא הוגבלה בכוונה, וזה מה ש-hidden_subsections מסמן. section_count נשאר 4. העומק נספר לאורך ה-breadcrumb ולא לפי level, וההגבלה היא של המפה בלבד — הפירוט ב-איך docs_get_section מוצא כותרת.
הכול כאן הוא של codekeeper_docs_get_section, ולא עותק שלו. הפרסור ומיפוי הסירובים של הפרסר (parse_with_refusals), וההתאמה, החיתוך, העימוד ובניית התשובה (answer_section) — אלה אותן פונקציות ב-mcp_server/docs_handlers.py, שני הכלים קוראים להן, ו-tests/test_mcp_file_sections.py מקבע שזה כך. לכן כל מה שכתוב ב-איך docs_get_section מוצא כותרת חל כאן כמו שהוא: שוויון מלא אחרי נרמול, קיצור המזהה (section="K11" מוצא את K11. ...), כותרת שנושאת בקטיקים או ** דורשת אותם גם בשאילתה, section_not_found עם מפה והצעות, ambiguous_section עם מועמדים, ותקרת האורך section_too_long. וגם העימוד: max_chars (ברירת מחדל 12,000, בין 500 ל-100,000, ערך מחוץ לטווח נצמד ואינו נדחה) ו-offset — כל עוד truncated הוא true ממשיכים מ-next_offset. וכך גם תקציב הבתים — של העמוד, ושל הרשימות במפה, בהצעות ובמועמדים, שנחתכות מהסוף — התקרה על subsections, section_too_large ו-answer_too_large (שם, בסוף ”איך docs_get_section מוצא כותרת“). ובקובץ שמור זה חשוב יותר מבתיעוד: קובץ עד MAX_FILE_SIZE_FOR_DISPLAY מגיע לפרסור, כלומר סעיף יכול להיות גדול בהרבה מהתקציב, וסעיף בתווים רחבים — CJK, אימוג’י, או עברית לצד רשימת תת-סעיפים ארוכה — עלול לקבל עמוד שנגמר לפני max_chars, עם truncation_reason: "byte_budget". עמוד מלא בעברית לבדה נכנס (שני בתים לתו). העמוד והמפה כאן שומרים מקום גם ל-found ול-status, כך שהתשובה כפי שהיא יוצאת מ-codekeeper_get_file נכנסת ב-OUTPUT_BYTE_BUDGET, ו-section_too_large ו-answer_too_large נושאים כאן גם hint. בשניהם max הוא OUTPUT_BYTE_BUDGET עצמו, ו-bytes הוא המספר שהושווה אליו — כולל המקום ל-found ול-status — ולכן bytes > max תמיד. עד #3472 max היה התקציב פחות המקום הזה, מספר שלא הופיע בשום תיעוד ושהשתנה לפי המצב. ובקבצים השמורים של היום אף תשובה של שלוש הצורות שנושאות רשימות אינה מגיעה לתקציב — לפי חסם עליון שנמדד במסד על כל מסמכי ה-Markdown השמורים ב-2026-09-28; המספרים והשאילתה בגוף PR #3470. התשובה היא התשובה של כלי התיעוד כמו שהיא; מה שנוסף הוא found ו-status בראשה, ובמקום repo, path, ref ו-resolved_commit היא נושאת file — המטא-דאטה של הקובץ בלי התוכן, גם בסירוב, כדי שיהיה ברור איזה קובץ ואיזו גרסה נקראו.
ושלושה הבדלים מכוונים מכלי התיעוד:
המפה היא מצב משלה,
toc=true, ולא ”בליsection“. קריאה בלי אף פרמטר מחזירה כאן את הקובץ המלא, כמו תמיד — ולכןsectionריק או רווחים בלבד אינו מפה בשקט אלא סירוב,empty_section, עם הפניה ל-toc=true. קודם אורך, אחר כך תוכן: רווחים בלבד שארוכים מתקרת האורך הםsection_too_long, כמו כלsectionארוך — ובדיוק כמו בכלי התיעוד. עד #3472 הם היו כאןempty_section, כלומר שני הכלים שעונים באותה פונקציה נתנו שתי שגיאות לאותו קלט.תת-הסעיפים תמיד כלולים.
include_subsectionsאינו פרמטר כאן. מי שצריך סעיף בלי ילדיו קורא ב-linesאת הטווח שלו עד השורה שלפני תת-הסעיף הראשון —subsectionsנושא את טווחי השורות.includesתמיד[]— זה שדה של RST, והמצבים האלה פועלים רק על Markdown.
איזה קובץ נקרא לפי סעיף
רק Markdown, לפי הכלל של הוובאפ: שפה markdown או md, או שם שנגמר ב-.md או ב-.markdown — בלי תלות ברישיות. הכלל יושב במקום אחד, services/markdown_files.py, והוובאפ וכלי הקבצים קוראים ממנו: קובץ שהוובאפ מציג כ-Markdown הוא קובץ שהסוכן יכול לקרוא לפי סעיף, ולהפך. קובץ אחר מקבל not_markdown — ולא את הקובץ המלא בשקט, כי קורא שביקש מפה וקיבל תוכן לא היה יודע שמה שביקש לא קרה.
הגרסה: file_name עם version קורא את הגרסה הזאת; file_id קורא את הגרסה שהוא מצביע עליה, ומתעלם מ-version — בדיוק כמו בקריאה מלאה. קריאה בכמה שלבים — העבירו file_id=file.id. מפה, אחריה סעיף, אחריו העמוד הבא, ואחריו lines על line_range שחזר: לפי file_name כל אחת מהקריאות האלה קוראת את הגרסה האחרונה. אם סשן אחר שמר גרסה באמצע, העמוד הבא יכול להיפתח באמצע מילה, או לחבר שתי גרסאות — בלי שום סימן חוץ מ-file.version שהשתנה. file_id מהתשובה הראשונה קורא בכל שלב את אותה גרסה בדיוק. זה המשפט הראשון בתיאורי הפרמטרים toc ו-section, בכוונה בתחילתם: לקוח שמקצר תיאור פרמטר רואה רק את ההתחלה. ותשובה לפי file_id על גרסה פעילה נושאת file.is_latest, שאומר אם הגרסה שננעלה היא עדיין זו שקריאה לפי השם מחזירה — false כשהיא מחזירה עכשיו גרסה אחרת, למשל כי נשמרה חדשה בינתיים. null כשאי אפשר לקבוע, והשדה נעדר כשהגרסה בסל (האם זו הגרסה האחרונה — is_latest). וההרשאה: רק קובץ של המשתמש המחובר. מזהה של קובץ של משתמש אחר מחזיר {"found": false}, כמו בקריאה מלאה.
תקרת הגודל נבדקת לפני הפרסור, ובבתים. קובץ שגדול מ-MAX_FILE_SIZE_FOR_DISPLAY (512,000) בבתים של UTF-8 מקבל too_large_for_sections, עם bytes ו-max. זו התקרה שמאגר הקריאות מתקצב לפיה פרסור אחד (ראו מודל הריצה של הכלים). בתים ולא תווים, כי העלות נמדדה לכל בית של קלט, ותו עברי הוא שני בתים — ספירת תווים הייתה מעבירה קובץ עברי כפול מהתקרה. אחרי הבדיקה הזו חלות אותן תקרות של md_parser כמו בכלי התיעוד — too_many_lines, too_many_tokens, inconsistent_line_endings ו-too_many_sections, עם אותם שדות (ראו איזה קובץ docs_get_section קורא, ובאיזה ריפו).
כל אחד מהסירובים האלה נושא hint שמפנה ל-lines=[start, end] או ל-query="...": הקובץ עצמו קריא, רק לא לפי סעיף. זה ההבדל היחיד מאותו סירוב בכלי התיעוד, והוא מכוון — בקובץ שמור אין ריפו לתקן בו, ומה שהקורא יכול לעשות מיד הוא לקרוא בדרך אחרת.
טקסט הסעיף זהה בית-בית לקריאת lines= על הטווח שלו — גם בקובץ עם \r\n, וגם עם BOM בתחילתו. כלומר line_range שחזר הוא טווח שאפשר לבקש בפועל, ומה שיחזור ב-lines הוא אותו טקסט בדיוק. זה נכון גם ל-codekeeper_docs_get_section על עמודי Markdown: שורות המסמך של md_parser הן שורות המקור, ולא הטקסט המנורמל שהפרסר רואה. rst_parser לא השתנה, ובעמוד RST הטקסט עדיין מנורמל.
מצבי הקצה
קלט |
התנהגות |
|---|---|
|
כל זוג נדחה בקוד משלו: |
|
|
|
|
|
|
|
|
|
|
קובץ שאינו Markdown |
|
קובץ מעל התקרה |
|
קובץ שהפרסר מסרב לו |
אותו קוד ואותם שדות כמו בכלי התיעוד, ובמקום ההקשר של הריפו — |
כותרת שאינה בקובץ, או כותרת כפולה |
|
|
|
עמוד שאינו נכנס בתקציב הבתים (תווים רחבים, |
העמוד נגמר מוקדם, על גבול תו: |
סעיף עם יותר תת-סעיפים מהתקרה של המפה |
|
כותרות שגדולות מהתקציב לבדן (גם עמוד ריק אינו נכנס) |
|
מפה שאינה נכנסת בתקציב הבתים (כותרות רבות, ארוכות או מקוננות עמוק) |
המפה נחתכת מהסוף עד שהתשובה נכנסת, עם |
|
ב- |
תשובה שגם כשכל הרשימות שלה ריקות אינה נכנסת |
|
סירוב שנושא |
הסירוב מוותר על |
|
הקובץ המלא חוזר, ולא סעיף, חיפוש או סירוב. הכלי מקבל את המחרוזת |
איפה המפה חולקת על הוובאפ
המפה היא של md_parser: CommonMark, עם טבלאות ועם front matter (על הפער מול GitHub — איפה מפת ה-Markdown חולקת על GitHub). הוובאפ מרנדר קבצים שמורים בדפדפן, ב-markdown-it עם html: false ועם קופסאות ::: — details וסוגי ההערה כמו note, tip ו-warning (webapp/templates/md_preview.html). הדיאלקט לא יושר, בכוונה, והוא מתועד כאן במקום זה. הוא נבדל בשלושה מקומות, וכל אחד מהם נבדק מול שני הצדדים — הפרסר שלנו, ו-markdown-it 14.1.0 עם markdown-it-container 4.0.0 מ-node_modules באותן אפשרויות של הוובאפ (2026-09-28).
1. כותרת בתוך קופסת :::. אצלנו ::: אינו תחביר — השורות שלו הן טקסט רגיל, והכותרת שבתוך הקופסה היא סעיף רגיל במפה. מה שהסוכן מקבל נבדק על אחד הקבצים השמורים שיש בהם כותרת בתוך קופסה, ועל הדוגמה הסינתטית הזו, שבנויה כמוהו:
## התקנה
הוראות.
::: details הערות מתקדמות
## הגדרות נסתרות
- ערך א
:::
## שימוש
דוגמה.
הכותרת שבתוך הקופסה נמצאת במפה, באותה רמה כמו השכנות שלה:
התקנהבשורות 1–5,הגדרות נסתרותבשורות 6–10,שימושבשורות 11–13.טקסט הסעיף שלה כולל את ה-
:::הסוגר, ואת מה שבא אחריו עד הכותרת הבאה:"## הגדרות נסתרות\n- ערך א\n\n:::\n".שורת הפתיחה של הקופסה, עם הכותרת שלה, שייכת לסעיף שלפניה, ונמצאת בסוף הטקסט שלו:
"## התקנה\nהוראות.\n\n::: details הערות מתקדמות\n".
למה לא ליישר: בוובאפ הכותרת הזו יושבת בתוך הקופסה — ברמת קינון 1 ולא 0 — והמפה לוקחת רק כותרות מהרמה העליונה. כלומר פרסר שמכיר קופסאות היה מעלים מהמפה כל כותרת שבתוך קופסה. כותרת שמופיעה עם שורת ::: בטקסט שלה עדיפה על כותרת שלא מופיעה בכלל.
2. כותרת מיד אחרי שורת HTML. אצלנו HTML פעיל (ה-preset commonmark של markdown-it-py), ולכן שורה כמו <details> פותחת בלוק HTML שנמשך עד השורה הריקה הבאה — ו-## כותרת שבאה מיד אחריה, בלי שורה ריקה, נבלעת בבלוק ואינה במפה. בוובאפ (html: false) השורה היא טקסט, והכותרת היא כותרת. שורה ריקה בין השתיים מבטלת את ההבדל. במדידה על הקבצים השמורים (2026-09-28) לא נמצא קובץ בצורה הזו.
3. front matter. אצלנו בלוק שנפתח ב---- בשורה הראשונה ונסגר ב---- הוא front matter, ואינו חלק מאף סעיף. בוובאפ אין front matter: ה---- הראשון הוא קו מפריד, ושורות ה-YAML, עם ה---- שסוגר אותן, הופכות לכותרת (setext, רמה 2) — כותרת שהוובאפ מציג ושאין במפה.
גודל וספירת שורות ברשימת הריפו
codekeeper_list_repo_tree עם include_stats=true מוסיף לתשובה מפתח
entries — רשומה לכל נתיב באותו עמוד ובאותו סדר של paths:
{"path": "webapp/app.py", "size": 48219, "lines": 1204, "lines_commit_sha": "a1b2c3d"}
paths עצמו אינו משתנה, וללא הדגל התשובה זהה לחלוטין לזו של היום.
מאיפה כל שדה מגיע, ולמה זה משנה:
size— מ-git ls-tree -r -l, כלומר גודל הבלוב בבייטים. אפס עלות נוספת: אותה קריאת git יחידה שממילא מרכיבה את הרשימה, רק עם עמודה נוספת. הערך הואnullעבור תת-מודול (submodule): אין לו גודל בבייטים כי התוכן שלו חי בריפו אחר. הנתיב עצמו אינו מושמט — תת-מודול מופיע ברשימה בדיוק כמו כל נתיב אחר, רק בלי גודל.lines— מאוסףrepo_files, שהאינדקסר (services/code_indexer.py) ממלא. שאילתה אחת לעמוד, חסומה בגודל העמוד ולא בגודל הריפו, ונשענת על האינדקס(repo_name, path)שהשירות מוודא בעלייה. את הווידוא הזה עושהDatabaseManager.safe_create_index— הוא ולא קוד יצירה מקומי, כי רק הוא קורא את האינדקס הקיים אחרי התנגשות ומאשר רק אם המפתחות וגםuniqueתואמים. הדגל הואunique=True, בדיוק כפי ש-scripts/create_repo_indexes.pyמצהיר: הצמד הזה הוא הזהות שה-upsert של האינדקסר מניח, ואינדקס לא-ייחודי על אותם מפתחות היה חוסם את יצירת הייחודי. git אינו יכול לספק ספירת שורות בלי לקרוא כל בלוב בנפרד, וזו עלות שלא משלמים כאן.lines_commit_sha— הקומיט שבו הספירה נלקחה.
חשוב
lines הוא null כשהקובץ מעולם לא אונדקס (למשל קובץ גדול שהאינדקסר
מדלג עליו), והוא עלול להיות מיושן: את repo_files ממלא הסנכרון של
הוובאפ, בעוד ה-autosync של שירות ה-MCP מריץ git fetch בלבד. ספירה
מיושנת גרועה מספירה חסרה, כי היא נראית תקינה — ולכן lines_commit_sha
נשלח לצידה. שוו אותו ל-ref שביקשתם כדי לדעת אם הספירה מתארת את הגרסה
שאתם קוראים.
עמוד שאינו נכנס בתקציב הפלט נדחה כולו, ולא נחתך (#3481): {"ok": false,
"error": "page_too_large", "bytes": ..., "max": 256000, "per_page": 1000},
בדיוק כמו באאוטליין — ובקשה עם per_page קטן יותר מחזירה את אותם נתיבים
בעמודים שלמים. bytes הוא גודל התשובה כולה כפי שהייתה נשלחת. עם
include_stats הרשומות שמנות יותר, ולכן אותו per_page עשוי להידחות איתו
ולהיכנס בלעדיו. עד #3481 עמוד כזה נחתך באמצע (truncated: true), והעמוד
הבא התחיל אחרי per_page המלא — כך שהנתיבים שבין נקודת החיתוך לסוף העמוד
לא חזרו באף עמוד. truncated נשאר בתשובה כדי שצורתה לא תשתנה, והוא תמיד
false. paths ו-entries נשארים באותו אורך ובאותו סדר.
קריאה קבוצתית — codekeeper_read_batch
כלי אדמין שקורא כמה סעיפי תיעוד וקבצים מהמראות בקריאת כלי אחת. הצורך נולד מסוכן ריוויו: לפני כל סבב הוא חייב לקרוא כל מסמך ב-amir-bug-patterns שהטריגר שלו נדלק על הדיף, ובסבב שבו הוא דיווח על כך דרך get_more_tools אלה היו 16 קריאות נפרדות. הקוד: mcp_server/read_batch.py; השקילה והרישום: mcp_server/server.py.
למה באץ«, ולא קריאות במקביל
הקריאות של סבב מגיעות לשרת אחת אחרי השנייה ולא יחד, וכמעט כל הזמן שלהן עובר מחוץ לשרת. זה נמדד ב-PostHog, ולא הונח. בסבב של 2026-09-23 (סשן ses_01a0cd48, בין 11:41:33 ל-11:43:49 UTC) הקריאות הגיעו בארבעה פרצים של ארבע, ובתוך כל פרץ — במרווחים של 0.64 עד 1.35 שניות זו מזו. ארבעת הפרצים נמשכו יחד כ-11.5 שניות, והשרת עבד מתוכן כ-463 אלפיות שנייה: כל קריאה נענתה בין 11 ל-181 אלפיות שנייה. גם בכל התנועה מאז המעבר לפרנקפורט (17–23.9; אירועי $mcp_tool_call של codekeeper_docs_get_section ו-codekeeper_get_repo_file, המרווח בין קריאות עוקבות באותו $session_id) רק 3 מתוך 491 מרווחים היו קצרים מ-50 אלפיות שנייה, והעשירון התחתון היה 837 אלפיות שנייה — בזמן שהחציון של זמן השרת היה 19.4 אלפיות שנייה לסעיף ו-9.9 לקובץ. כלומר רוב הזמן של סבב אינו השרת.
מה ממלא את המרווח. Claude Code מתעד שהוא מריץ כלים לקריאה בלבד במקביל (CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY, עשרה כברירת מחדל; code.claude.com/docs/en/env-vars.md, נקרא ב-2026-09-23), ובכל זאת אף פרץ לא הגיע יחד. יש שני הסברים אפשריים: המודל כותב את הקריאות אחת-אחת, וכל קריאה יוצאת כשהוא גומר לכתוב אותה; או שלכל קריאה נוסף זמן קבוע בדרך מהלקוח. תצפית אחת מהפרודקשן נוטה להסבר הראשון. שלושה באצ’ים נשלחו באותה הודעה (2026-09-23, Claude Code 2.1.281), והשני והשלישי, 20 פריטים כל אחד, הגיעו לשרת 6.7 ו-7.0 שניות אחרי הקודם — בזמן שהשרת עבד על כל באץ« 14 עד 18 אלפיות שנייה. בסבב שלמעלה, קריאות בודדות הגיעו במרווחים של 0.64 עד 1.35 שניות. מרווח שגדל עם אורך הקריאה מתאים לזמן שלוקח לכתוב אותה, ולא לזמן קבוע לכל קריאה. זו תצפית אחת, ולא מדידה מבוקרת.
ומה באץ« חוסך. באץ« הוא קריאת כלי אחת, ולכן הוא חוסך את מה שחוזר בכל קריאה — בשני ההסברים. אם לכל קריאה נוסף זמן קבוע, הוא משולם פעם אחת במקום N. אם הזמן הוא זמן הכתיבה, נחסך מה שהמודל כותב בכל קריאה מעבר לפריט עצמו: שם הכלי והמבנה של הקריאה, ומשפט ה-context שהאנליטיקס מבקש מכל קריאה (ראו ”לכידת הכוונה“ ב-מדידת שימוש (PostHog MCP Analytics)). את כתיבת הפריטים עצמם באץ« אינו חוסך: באץ« של N פריטים עדיין דורש לכתוב N פריטים. לכן באץ« הוא ”מרווח אחד במקום N“ רק בהסבר השני. כמה באץ« חוסך בסבב אמיתי לא נמדד. המדידה שתכריע: אותם פריטים באותה הודעה, פעם כקריאות בודדות ופעם כבאץ« אחד, והשוואה של זמני ההגעה ב-PostHog.
אדמין בהחלטה, ולא בירושה. פריטי הסעיף משקפים כלי ציבורי, אבל הצורך נולד מסוכן הריוויו של האדמין, פריטי הקובץ ממילא דורשים אדמין, ומשטח ציבורי שמכפיל עלות פרסור בקריאה אחת אינו נחוץ היום. הכלי רשום ב-_REPO_BROWSER_TOOLS, מוסתר מ-tools/list לכל מי שאינו אדמין, ו-require_admin היא השורה הראשונה בגוף שלו.
פריט, ומה הוא מחזיר
שני סוגי פריטים, וכל אחד עובר בדיוק דרך ה-handler של הכלי שהוא משקף — אין כאן מסלול קריאה שני. פריט סעיף {"kind": "section", "path", "section"?, "repo"?} עובר דרך החלקים ש-codekeeper_docs_get_section עצמו עשוי מהם: resolve_docs_target, load_document ו-answer_section ב-mcp_server/docs_handlers.py, עם ברירות המחדל של העימוד שלו. פריט קובץ {"kind": "file", "repo", "path", "lines"?} עובר דרך repo_handlers.get_repo_file. לכן ה-result של כל פריט זהה בית-בית למה שהכלי הבודד מחזיר על אותם ארגומנטים, כולל תשובות הסירוב, וכישלון של פריט אינו כישלון של הקריאה:
{"ok": true, "count": 2, "items": [
{"index": 0, "request": {"kind": "section", "path": "CRITICAL-PATTERNS", "section": "K11"},
"resolved_commit": "456e90b66c49c731006656a27b77a28b21163252",
"result": {"ok": true, "mode": "section", "section": "K11. …", "content": "…"}},
{"index": 1, "request": {"kind": "file", "repo": "amir-bug-patterns", "path": "nope.md"},
"resolved_commit": "456e90b66c49c731006656a27b77a28b21163252",
"result": {"ok": false, "error": "not_found", "ref": "refs/heads/main",
"resolved_commit": "456e90b66c49c731006656a27b77a28b21163252"}}
]}
הסדר הוא סדר הבקשה, ו-
indexהוא המקום של הפריט בה.countהוא מספר הפריטים שבתשובה.פריט אינו מקבל ``ref``. כל ריפו נקרא בענף הראשי שלו, מקובע ל-commit אחד לכל הקריאה (למטה). לקריאה ב-ref אחר יש את הכלים הבודדים.
פריט סעיף נקרא בברירות המחדל של העימוד —
max_charsהואMAX_CHARS_DEFAULT, ו-offsetהוא 0. סעיף ארוך יותר חוזר עםtruncatedו-next_offset, כמו תמיד, וממשיכים אותו ב-codekeeper_docs_get_sectionעצמו.פריט קובץ אינו מקבל ``outline``, ``symbol`` או ``page``. הבאץ« קורא תוכן; מפה של קובץ נשארת בכלי הבודד.
כל מפתח אחר, או ערך מטיפוס לא נכון, הוא ``invalid_item`` של אותו פריט בלבד, עם
problemsשאומר איפה ומה — בלי להדהד את הערך שנשלח, כי הוא יכול להיות תוכן. הבדיקהstrict:lines=[true, 5]נדחה ולא הופך ל-[1, 5], בדיוק כמו בכלי הבודד. הסכימה שהלקוח רואה לפריט נגזרת משני המודלים שמאמתים אותו (SectionItemו-FileItem), כך שהחוזה המוצהר והאכיפה אינם יכולים להיפרד.פריטים כפולים מותרים, והם חולקים את אותה קריאה.
commit אחד לכל ריפו
בתחילת הקריאה RepoBackend.snapshot בונה תמונת מצב (ReadSnapshot ב-mcp_server/repo_backend.py). היא שואלת שלוש שאלות, כל אחת פעם אחת לכל ריפו ולא לכל פריט: מה הענף הראשי, לאיזה commit הוא מצביע עכשיו (GitMirrorService.resolve_commit), והאם סנכרון רץ — את האחרונה רק במסלול הכשל. כל הפריטים של אותו ריפו נקראים מה-SHA הזה, ולכן autosync שמושך באמצע הבאץ« אינו מפצל את התשובה בין שני commits.
התשובה נושאת את ה-``ref`` שהכלי הבודד היה כותב — שם הענף ולא ה-SHA — כדי שה-result יישאר זהה בית-בית. ה-SHA עצמו מופיע ב-resolved_commit: בתוך ה-result כמו תמיד, וגם ברמת הפריט, כך שאפשר לראות מאיזה commit בא כל פריט בלי להכיר את צורת התשובה של כל כלי. פריט שלא הגיע ל-commit (סירוב של השער, ref שלא נפתר) אינו נושא resolved_commit; not_found כן נושא אותו, כי הוא אומר באיזה commit הקובץ לא נמצא.
אזהרה
כשהקיבוע נכשל באופן זמני, פריטים של אותו ריפו יכולים להגיע מ-commits שונים. אם resolve_commit עונה timeout או internal_error, השרת רושם WARNING אחד לריפו, והפריטים שלו נקראים לפי שם הענף — כל אחד פותר אותו בנפרד, כמו הכלי הבודד. autosync שמושך באותו רגע יכול אז לפצל אותם בין שני commits. זה לא נמנע, אבל זה גלוי: resolved_commit של כל פריט אומר מאיזה commit הוא בא, ומי שצריך commit אחד בודק שכולם שווים. קוד שאומר ”אין כזה“ (invalid_ref, ref_not_mirrored, repo_not_found) אינו נרשם: הוא חוזר מהקריאה עצמה בשמו, בדיוק כמו בכלי הבודד. מראה שבורה (git_error) נרשמת כבר בשירות המראה, עם ה-stderr.
קריאה אחת לכל קובץ, ושני סדרים
הפריטים מקובצים לפי הקובץ שהם קוראים — ``(repo, path, lines)`` — וכל קבוצה נקראת ומפורסרת פעם אחת, כשהמעבר מגיע לפריט הראשון שלה. כך K11 ו-K13 מאותו CRITICAL-PATTERNS.md עולים קריאה אחת ופרסור אחד. המסמך משתחרר כשהקבוצה נענתה, לפני שהקבוצה הבאה נקראת. אין מטמון ואין פינוי: בסבב האמיתי רק שני זוגות של פריטים חלקו קובץ, ומטמון עם תקרה היה מנגנון בלי עבודה.
``lines`` הוא חלק מהמפתח, וזה לא עניין של יעילות. קריאה מלאה נשפטת מול 500KB (MAX_FILE_SIZE_FOR_DISPLAY), וקריאת טווח מול RANGE_READ_MAX_BYTES — ותקרת ה-500KB היא ההגנה היחידה של הפרסור. פריט סעיף הוא תמיד קריאה מלאה, ולכן הוא חולק קבוצה רק עם פריט קובץ בלי lines. קובץ של 600KB שמבקשים בו גם טווח וגם סעיף, באותו באץ«, מחזיר לפריט הסעיף unreadable_too_large — בדיוק כמו הכלי הבודד — ואינו מפרסר טקסט שנקרא תחת התקרה של טווח.
שני סדרים, ואיך הם חיים יחד. הקריאה מהמראה נעשית לפי הקבוצות, אבל התשובה נבנית ונמדדת לפי סדר הבקשה. המעבר הולך על האינדקסים 0, 1, 2…, וכשהוא מגיע לפריט שהקבוצה שלו עוד לא נקראה, הוא קורא אותה ועונה באותה הזדמנות על כל הפריטים שלה — גם על אלה שבהמשך. תשובה כזו מחכה עד שהמעבר מגיע לאינדקס שלה, ורק אז נכנסת לתקציב. לכן התשובה היא תמיד רצף מתחילת הבקשה, ו-unread הוא תמיד הסיפא שלה: כשפריט אינו נכנס בתקציב, או שהדדליין הגיע לפני שהקבוצה שלו נקראה, המעבר נעצר, והפריט הזה וכל מה שאחריו מדווחים ב-unread. זה כולל פריט שכבר נענה כחלק מקבוצה, כי תשובה שמופיעה אחרי החור לא תיכנס. זה מה שמאפשר unread_reason אחד, ואת הכלל הפשוט ללקוח: שולחים שוב את כל מה שב-unread.
והזיכרון. מה שכבר נכנס לתשובה, ועוד כל תשובה שמחכה לתורה, אינם עוברים יחד את OUTPUT_BYTE_BUDGET — זה האינווריאנט של _keep. תשובה נשמרת רק אם היא עוד תיכנס אחרי כל מה שלפניה. וכשתשובה חדשה נשמרת לפני תשובות שכבר מחכות, הראשונה מהן שכבר לא תיכנס מסומנת כנקודת העצירה ומפונה מיד, יחד עם כל מה שאחריה — המעבר ייעצר בה ממילא. בלי הפינוי הזה החסם לא היה קיים: קבוצה שנקראת עונה גם על פריטים רחוקים בבקשה, וכל תשובה רחוקה שנבדקה רק מול מה שלפניה נכנסה לבדה. בבאץ« של עשרה סעיפים ואחריהם עשרת הקבצים שלהם בסדר הפוך, התשובות שחיכו יחד היו פי כמה מהתקציב, ואף אחת מהן לא נשלחה (test_answers_waiting_for_their_turn_never_hold_more_than_the_budget). הפינוי אינו משנה את התשובה ללקוח, כי פריט שפונה לא היה נכנס ממילא, ותשובה לפריט שאחרי נקודת העצירה אינה נבנית כלל. מעל התקציב יושבים רק הקריאה של הקבוצה הנוכחית — קובץ אחד והמסמך המפורסר שלו — ותשובה אחת שנבנית ברגע זה. נמדד ב-scripts/measure_read_batch.py (2026-09-23, מראה של amir-bug-patterns ב-commit 456e90b6, שיא ההקצאות של פייתון ב-tracemalloc): קריאה בודדת של סעיף מהקובץ הגדול ביותר שהכלי מגיש (107,494 בתים) — 2.50MiB; באץ« מלא של סעיפים שמדלג בין חמשת הקבצים הגדולים — 2.51MiB; הסבב האמיתי — 0.74MiB. כלומר באץ« עולה את הקריאה היקרה שבו, ולא את סכום הקריאות. מאגר הקריאות מתומחר לפי פרסור אחד לחוט (ראו מודל הריצה של הכלים), ולכן הוא אינו צריך תמחור אחר בשביל הבאץ«. והבאץ« רץ כולו בחוט אחד של המאגר, פריט אחרי פריט, בלי חוטים משלו.
תקציב הבתים, ולמה בתים הם גם תווים
התקציב הוא ``OUTPUT_BYTE_BUDGET`` לכל התשובה, והוא נמדד בצורה שה-SDK שולח. תשובת dict של כלי יוצאת כ-pydantic_core.to_json(..., indent=2) (_convert_to_content ב-mcp 1.28.1), והכלי מודד בדיוק את זה — כולל ההזחה של כל פריט בתוך items — ולא את json.dumps הדחוס. מאז #3460 כך נמדד כל כלי שמצהיר על תקרת תשובה, באותה פונקציה (answer_size.wire_json); ראו תקרת התשובה — מה כלי מצהיר, ומי אוכף. הצורה שנשלחת לעולם אינה קטנה מהדחוסה, כי היא מוסיפה רק רווחים ושורות, ולכן תשובה שנכנסת במדידה הזו נכנסת בשתיהן. המעטפת שמורה מראש: לפני הפריט הראשון נשמר המקום של המעטפת במקרה הגרוע — כל האינדקסים ב-unread והסיבה הארוכה מבין השתיים — כך שהמעטפת האמיתית לעולם אינה דוחקת פריט שכבר נכנס.
פריט נכנס שלם, או לא בכלל. אין חיתוך בתוך פריט. פריט שגדול לבדו מכל מה שתשובה יכולה להכיל מקבל במקום התשובה שלו את הגודל שהיה לו, את התקרה, ואת הכלי הבודד שיקרא אותו. זו תשובה אמיתית, על קובץ עברי של 444,012 בתים בבאץ« של פריט אחד:
{"ok": false, "error": "item_too_large", "bytes": 456557, "max": 255893, "read_with": "codekeeper_get_repo_file"}
max הוא מה שנשאר לפריט אחרי המעטפת השמורה, ולכן הוא תלוי במספר הפריטים בבאץ«. פריט קובץ יכול גם לצמצם את עצמו ב-lines.
למה בתים הם גם תקרת תווים. Claude Code שומר לקובץ תשובת כלי שעוברת את הסף שלו — 25,000 טוקנים כברירת מחדל (MAX_MCP_OUTPUT_TOKENS) — ומחליף אותה בהקשר בהפניה לקובץ. כלי יכול להעלות את הסף של עצמו עד 500,000 תווים, ב-_meta["anthropic/maxResultSizeChars"] שהוא מצהיר ב-tools/list (code.claude.com/docs/en/mcp.md, ”Raise the limit for a specific tool“, נקרא ב-2026-09-23). בלי ההצהרה, באץ« של סבב ריוויו היה מגיע כקובץ ולא להקשר — וזה קרה לכלים הבודדים: קובץ עברי של 79,626 בתים שנקרא ב-codekeeper_get_repo_file נשמר לקובץ, עד שגם הם הצהירו (#3460, תקרת התשובה — מה כלי מצהיר, ומי אוכף). הכלי מצהיר DECLARED_MAX_RESULT_CHARS, שהוא OUTPUT_BYTE_BUDGET עצמו, כמספר תווים, ובכוונה: בכל טקסט UTF-8 מספר התווים אינו עולה על מספר הבתים — וגם לא מספר יחידות ה-UTF-16 שבהן JavaScript סופר אורך — ולכן תשובה שחסומה ב-OUTPUT_BYTE_BUDGET בתים לעולם אינה עוברת את מה שהוצהר. ההזחה כבר בתוך המדידה, ואין מה להוסיף עליה.
ההצהרה נבדקה בפרודקשן (2026-09-23, Claude Code 2.1.281 בסשן ענן). אותו קובץ של 79,626 בתים נשמר לקובץ כשנקרא ב-codekeeper_get_repo_file (52,158 תווים), ונכנס להקשר במלואו כשנקרא כפריט יחיד בבאץ« (52,542 תווים). התשובה של הבאץ« גדולה יותר בגלל המעטפת, ובכל זאת נכנסה — כלומר מה שהכניס אותה הוא ההצהרה, ולא הגודל. גם הסבב האמיתי נכנס במלואו (71,425 תווים). ומכאן שאותה הצהרה על הכלים הבודדים אמורה להכניס להקשר גם את התשובות שלהם — וזה מה ש-#3460 עשה (תקרת התשובה — מה כלי מצהיר, ומי אוכף). בצ’אט של claude.ai (הלקוח Anthropic/ClaudeAI ב-PostHog) באץ« של פריט אחד על אותו קובץ הגיע לשיחה שלם, אבל שם לא הורצה אותה קריאה בכלי הבודד, ולכן זה לא מראה אם ההצהרה היא שעשתה את ההבדל. אפליקציית הדסקטופ לא נבדקה.
הדדליין
``DEADLINE_SECONDS`` — עשר שניות קיר, מהרגע שהקריאה נכנסה ל-``call_tool``. לא מתחילת הגוף: בין השניים יש המתנה לחוט פנוי במאגר הקריאות, וגם היא על השעון של הלקוח. call_tool רושם את הרגע הזה ראשון, ומעביר אותו לגוף ב-ContextVar (asyncio.to_thread מעתיק את ההקשר לחוט). כשהדדליין עבר, המעבר עוצר לפני הקריאה הבאה מהמראה: מה שכבר נקרא חוזר, ומה שלא נקרא מדווח ב-unread עם unread_reason: "timeout". פריט שכבר נענה — סירוב של השער, או חבר בקבוצה שכבר נקראה — נכנס גם אחרי הדדליין, כי העבודה שלו כבר נעשתה.
מול איזו מגבלה של הלקוח. ל-Claude Code שתי מגבלות על קריאת כלי לשרת HTTP, וזה מה שהתיעוד שלו אומר (code.claude.com/docs/en/mcp.md, נקרא ב-2026-09-23). האחת: טיימר לכל בקשה, 60 שניות כברירת מחדל, ש“מכסה כל בקשה עד הבית הראשון של התשובה מהשרת“. השנייה: חלון idle של חמש דקות — קריאה שלא הגיעה ממנה תשובה ולא הודעת התקדמות במשך החלון מבוטלת (מ-Claude Code 2.1.187; הסבב שהוליד את הכלי רץ על 2.1.280). השרת הזה עונה על tools/call בזרם SSE: json_response כבוי — ברירת המחדל של FastMCP, ש-build_mcp אינו משנה — ו-mcp/server/streamable_http.py (mcp 1.28.1) מתחיל את תשובת ה-SSE, כלומר שולח את הכותרות, לפני שהוא מעביר את הבקשה לכלי. כך גם נמדד בהרצה מקומית: הכותרות הגיעו תוך אלפיות שנייה, גם מכלי שרץ 12 שניות. כלומר היום טיימר ה-60 נסגר מיד, ומה שמגביל קריאה ארוכה מצד הלקוח הוא חלון ה-idle. אבל בתשובות JSON (json_response=True) הבית הראשון הוא התשובה עצמה, ואז 60 השניות מכסות את כל הריצה — ולכן הדדליין נבחר מול המחמירה מבין השתיים. ההתנהגות מאחורי הפרוקסי של Render לא נמדדה.
חשוב
ההנחה שהמספר נשען עליה: הדדליין נבדק בין פריטים בלבד. פריט שהתחיל רץ עד סופו, ולכן באץ« יכול לעבור את DEADLINE_SECONDS בכדי הפריט היקר ביותר שבו. היקר ביותר הוא פרסור Markdown תחת התקרות של md_parser — WORST_CASE_CPU_SECONDS ב-services/md_parser.py, שם גם הקלט, המדידה והתאריך: 0.66 שניות מעבד, שהן 13.2 שניות קיר תחת עומס מלא — עשרה חוטים של מאגר הקריאות על חצי מעבד. 10 + 13.2 = 23.2, מתחת ל-60, וממילא מתחת לחמש הדקות. ``tests/test_md_parse_worst_case_claims.py`` גוזר את החשבון הזה מהקבועים, ובודק שהשורה כאן היא החשבון שלהם. עד #3391 הפריט היקר לא היה חסום בכלל, והחשבון נשען על מדידה אחת שלו — שכבר לא הייתה הגרועה: רשימה מקוננת עלתה בערך פי שניים ממנה, והחשבון איתה היה עובר את 60. מי שמשנה את DEADLINE_SECONDS, את רוחב המאגר, את מכסת המעבד או את התקרות של md_parser — משנה את החשבון הזה. ומה שהחשבון אינו מכסה: קריאת git שנתקעת עד ה-timeout של עצמה (get_file_at_commit ב-services/git_mirror_service.py) היא כשל ולא קריאה איטית, והיא קיימת בכלי הבודד באותה מידה.
מכסת הקצב, ולמה אין החזר
כל פריט נספר כקריאה אחת, והכל או כלום, לפני שדבר נקרא — הפרטים ב-גבולות הבקשה — גודל הגוף והקצב. פריטים שלא נקראו (``unread``) אינם מחזירים את היחידות שלהם, וזו החלטה. המקרה נדיר: בפרודקשן הסבב האמיתי היה 98,981 בתים בצורה שנשלחת — 39% מהתקציב — ו-270 אלפיות שנייה, 2.7% מהדדליין (2026-09-23, Claude Code 2.1.281; הבתים נספרו על התשובה שהגיעה ללקוח, והזמן הוא $mcp_duration_ms של האירוע ב-PostHog). החזר היה מנגנון שני במגביל, עם מקרי קצה משלו (חלון שכבר התגלגל, קריאות מקבילות של אותה זהות), בלי שום דבר שמצדיק אותו היום. אם unread יתחיל להופיע בשימוש אמיתי, זה המקום לחזור אליו.
אותו סבב במדידה המקומית. REVIEW_ROUND ב-scripts/measure_read_batch.py מריץ את הסבב מעל מראה מקומית, באותו commit שהפרודקשן קרא (456e90b6), ומודד 98,816 בתים ו-135 אלפיות שנייה. הבתים שונים בדבר אחד: הסקריפט רץ בלי מונגו, ולכן RepoBackend._default_ref מחזיר בו HEAD, ובפרודקשן refs/heads/main (מ-repo_metadata) — 11 בתים פחות בכל פריט שנקרא. כשאותה ריצה מקבלת refs/heads/main כענף הראשי, היא מחזירה 98,981 בתים, בדיוק כמו בפרודקשן. הזמן שונה בשני דברים: הסקריפט מודד רק את read_batch.read_batch, ו-$mcp_duration_ms עוטף את ToolManager.call_tool — כולל אימות הארגומנטים, ההעברה לחוט של מאגר הקריאות וההמרה ל-JSON (_instrument_fastmcp.py ב-posthog 7.45.3, ו-FastMCP.call_tool ב-mcp 1.28.1); והפרודקשן רץ על מכונה אחרת. כמה מהפער בא מכל אחד מהשניים לא נמדד. מול הדדליין קובע המספר של הפרודקשן, ובפועל הזמן שנספר מול הדדליין קצת יותר ארוך ממנו: הדדליין נמדד מהכניסה ל-call_tool, עוד לפני הקטע ש-PostHog מודד.
ב-PostHog באץ« הוא אירוע אחד. $mcp_tool_call נרשם לכל קריאת כלי, ולכן באץ« הוא אירוע אחד עם משך אחד — ספירה לפי שם כלי אינה סופרת פריטים. הוא גם אינו נושא ck_read_mode (ראו מצב הקריאה (ck_read_mode)). וסירובי התקרה, missing_items ו-too_many_items, מוכרעים ב-call_tool לפני התפר שהמדידה עוטפת — בדיוק כמו rate_limited — ולכן אינם נראים שם כלל.