שרת ה-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 שחזר.

error

מתי, ומה בתשובה

מה הקורא עושה

ref_too_long

ref ארוך מ-MAX_REF_CHARS תווים — 4,096, אותו מספר ומאותו נימוק כמו MAX_PATH_CHARS, שניהם ב-mcp_server/docs_handlers.py. נבדק ב-RepoBackend לפני שהמראה נשאלת, ולכן גובר על ריפו או נתיב פגומים ועל ריפו בלי מראה; מה שנבדק לפניו עונה כמו תמיד — path_denied, outline יחד עם lines, טווח lines פגום, ובכלי התיעוד גם מדיניות הנתיבים. נושא max_chars ו-actual_chars ולא את ה-``ref``: עד היום invalid_ref החזיר אותו כמו שהוא, ו-ref ענק הוליד סירוב גדול מהתקציב, שהרשת החליפה בסירוב כללי בלי הסיבה. ב-codekeeper_docs_get_section גם repo ו-path

לתקן את השם; ref אמיתי קצר בהרבה מהתקרה

invalid_ref

השם לא עבר את הבדיקה הבסיסית (BASIC_REF_PATTERN), ולכן git לא נשאל עליו בכלל. נושא repo, ref ו-message, ובכלים שמקבלים נתיב גם path

לתקן את השם

ref_not_mirrored

השם תקין, והמראה של המארח הזה לא מכירה אותו — ענף או SHA. ”לא במראה“, ולא ”לא קיים“: המראה יודעת רק מה היה ב-GitHub במשיכה האחרונה שלה. נושא repo, ref ו-message, ובכלים שמקבלים נתיב גם path

לא לנסות שמות קבצים אחרים. ענף שנדחף לאחרונה יגיע עם השינוי הבא בענף הראשי

not_found

הענף נפתר, והקובץ אינו ב-commit שלו. נושא ref — מה שנקרא בפועל: מה שהתבקש, או הענף הראשי — ו-resolved_commit; ב-codekeeper_docs_get_section גם repo ו-path

לבדוק את הנתיב מול ה-commit שב-resolved_commit

repo_not_mirrored

למארח אין עותק של הריפו כלל

עניין של המפעיל

read_failed

הריפו נמצא והקריאה נכשלה: מראה שבורה, 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 קצר

CodeBot

docs/

.rst

mcp-server ← docs/mcp-server.rst

amir-bug-patterns

שורש הריפו

.md

CRITICAL-PATTERNS ← CRITICAL-PATTERNS.md

שני שערים, ולא אחד. 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 צמודה לשורת מכל (פריט רשימה או ציטוט)

## לפני, שורה ריקה, - פריט, ומיד אחריה <br> ואז ## אחרי

סעיף אחד: ## אחרי נבלע בבלוק ה-HTML

שני סעיפים: ## אחרי הוא כותרת

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

## לפני, שורה ריקה, טקסט, ומיד אחריה <source> ואז ## אחרי — ובאותה צורה עם <search>

עם <source>: סעיף אחד (## אחרי נבלע). עם <search>: שני סעיפים

עם <source>: שני סעיפים. עם <search>: סעיף אחד (## אחרי נבלע)

28 מתוך 60 צורות שנמדדו; markdown-it-py 4.2.0, cmarkgfm 2025.10.22; 2026-09-27

אין אישו — זה שינוי במפרט: source יצאה מרשימת התגיות ב-CommonMark 0.31, ו-search נמצאת בה (https://spec.commonmark.org/0.31.2/#html-blocks, תנאי פתיחה 6). markdown-it-py עובד לפי הרשימה הזאת מאז 4.0.0, ו-cmark-gfm עוד לפי הקודמת.

טבלה שעוברת את תקרת התאים המשלימים של markdown-it-py 4.x

# כותרת, שורה ריקה, טבלה ברוחב 300 עמודות עם 230 שורות גוף של תא אחד, ומיד אחריה ---

סעיף אחד: כל השורות בטבלה, ו---- הוא קו מפריד

שני סעיפים: הטבלה נחתכת בתקרה, השורות שנשארו בחוץ הן פסקה, ו---- הופך אותה לכותרת

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 שצמודה להגדרת קישור

[a]: /u, ומיד אחריה אחרי ו----

הכותרת מתחילה בשורה 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

## לפני, שורה ריקה, טקסט, ומיד אחריה <div, רווח שאינו נשבר (NBSP) ו-class="x">, ואז ## אחרי

שני סעיפים: אצל 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 בודד השתיים נפרדות, ולכן קובץ כזה נדחה במפורש במקום להחזיר מפה שמצביעה לשומקום. הערך היחיד של המפה הוא שאפשר להמשיך ממנה לטווח.

מצב

התשובה

סיומת שאין לה סורק

{"ok": true, "file": {...}, "status": "no_outline", "reason": "unsupported_language"}

סיומת שאין לה סורק, בקובץ ש-codekeeper_docs_get_section מגיש

אותה תשובה, ועוד הפניה: "read_with": "codekeeper_docs_get_section", "read_with_arguments": {"repo": "amir-bug-patterns", "path": "CRITICAL-PATTERNS.md"}. מריצים את הכלי שב-read_with עם הארגומנטים כמו שהם, ומקבלים את עץ הכותרות של אותו קובץ — ראו ההסבר מתחת לטבלה

תחביר שבור, פייתון 2, קידוד פגום

{"ok": true, "file": {...}, "status": "no_outline", "reason": "parse_error", "error_type": "UnicodeEncodeError", "line": 12}. error_type הוא שם מחלקת החריגה — בלעדיו אי אפשר לאבחן למה קובץ שנראה תקין לא נותן מפה

סופי שורות שאינם עקביים

{"ok": true, "file": {...}, "status": "no_outline", "reason": "inconsistent_line_endings"}. קובץ שמכיל \r שאינו חלק מ-\r\n — הפורמט של Mac שלפני 2001. CRLF אינו מושפע ועובד במלואו

עמוד גדול מתקציב הפלט

{"ok": false, "error": "page_too_large", "bytes": 292783, "max": 256000, "per_page": 500}. בקשו per_page קטן יותר; אין כאן חיתוך שקט. bytes הוא גודל התשובה כולה כפי שהייתה נשלחת — עם file, המונים וההזחה. עד #3460 נמדד רק חלון הסימבולים, דחוס, ועמוד שנמדד 247,684 בתים יצא 261,992

יותר סימבולים ממה שהמפה ממפה

{"ok": true, "file": {...}, "status": "no_outline", "reason": "too_many_symbols", "max": 50000}. הסריקה נעצרת ו**אין** symbols בתשובה — לא רשימה חלקית שנראית שלמה. אין כאן חיתוך, וגם לא total: נעצרנו, ולכן איננו יודעים כמה סימבולים יש בקובץ. התקרה רחוקה בכמה סדרי גודל מקובץ שאדם כתב, ולכן המקרה המעשי הוא קלט שנבנה בכוונה או פלט של מחשב

הפניה ל-``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 בעומק שתים-עשרה אינו שם משמעותי. התחילית נוספת רק כשיש עוגן אמיתי:

מה שבתבנית

השם במפה

{% block content %}

block content

{% macro nv(value) %}

macro nv

{% extends "base.html" %}, {% include %}, {% import %}

extends base.html — שורה אחת, start == end

<div id="foo">

div#foo

<script> בלי id

script; הפונקציות שבתוכו שטוחות: renderThemes

<script id="init">

script#init, והפונקציות script#init.renderThemes

חשוב

הוספת 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=.

מה שבקובץ

השם במפה

.card:hover { ... }

.card:hover

סלקטורים מרובים על כמה שורות

שם אחד, מנורמל לרווח יחיד: .a, .b:focus

@media (max-width: 768px) { ... }

@media (max-width: 768px)

@supports (display: grid) { ... }

@supports (display: grid)

@keyframes spin { 0% { ... } }

@keyframes spin; הצעדים שבתוכו אינם סימבולים

@import url(other.css);

אינו סימבול — at-rule בלי בלוק אין לה טווח

הבלוקים שבתוך @media הם סימבולים שטוחים בפני עצמם, בלי תחילית. ההשתייכות נקראת מטווח השורות, בדיוק כמו ב-HTML.

ההערה שמעל הסלקטור אינה חלק מהשם, ושורת ההתחלה היא של הסלקטור ולא של ההערה. הערה בתוך סלקטור אינה שוברת אותו: .a /* x */ .b הוא סלקטור אחד.

חשוב

בקובץ ממוזער כל הסימבולים מצביעים לאותה שורה, וזו התנהגות ולא באג. קובץ שכל תוכנו בשורה אחת מחזיר מפה שכל רשומה בה אומרת אותה שורה — כי שם הבלוקים באמת יושבים. ה-start וה-end נכונים, והחוזה נשמר: אפשר להמשיך מהם ל-lines=. מפה כזאת פשוט אינה מוסיפה ניווט, ומי שמחפש בלוק בקובץ ממוזער ימשיך ב-symbol= ולא בשורות.

RST

הסימבול הוא כותרת, והמפה היא תוכן עניינים עם טווחי שורות. זה בדיוק המקום שבו חיפוש טקסט מאכזב: שאילתה כמו רמת לוג תמצא אזכורים בפרוזה ולא את הסעיף שמגדיר אותה, ומפה שאומרת באילו שורות הסעיף יושב מאפשרת להמשיך ישר ל-lines=.

ומה שמעגן את הכללים שלמטה: זיהוי הכותרות מיושר לפארסר של docutils 0.23, ונבדק מול כל קובצי ה-RST של התיעוד הזה — אותן רמות ואותה ספירה בכל קובץ — ומול טבלאות של צורות כותרת סינתטיות. זה נאמר כאן כי הכללים נראים כמו החלטות שלנו, והם אינם: הם מה ש-Sphinx יעשה עם אותו קובץ.

חשוב

אין משמעות מובנית לתו הפיסוק. === אינו ”רמה 1“. ההיררכיה נקבעת לפי סדר הופעת התווים בכל קובץ בנפרד: התו הראשון שנתקלים בו הוא רמה 1, התו החדש הבא הוא רמה 2, וכן הלאה. לכן אותו קובץ יכול להשתמש ב-^ לרמה 3 ואחר ב-~, ושניהם תקינים.

השמות מנוקדים לפי ההיררכיה, כמו בפייתון: התקנה.דרישות.פייתון. כותרת המסמך הופכת לתחילית של כל שם, וזה מה שמפריד שתי כותרות זהות תחת אבות שונים.

המבנה

השם

כותרת עם קו תחתון

הכותרת, מנוקדת לפי האבות שלה

כותרת עם קו מעליה ומתחתיה

אותו דבר, והטווח מתחיל בשורת הקו העליון

.. _my-anchor:

_my-anchor, ו-start שווה ל-end

יעדי .. _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 קורא, ובאיזה ריפו). תשובת סעיף אינה נושאת אותה.

קלט

התנהגות

end מעבר לסוף הקובץ

מקוצץ לסוף הקובץ, ו-range.truncated הוא true

הטווח אינו נכנס ב-OUTPUT_BYTE_BUDGET בתים כפי שהוא נשלח

נגמר מוקדם, על גבול שורה: range.end הוא השורה האחרונה שחזרה בפועל, range.truncated הוא true ו-range.truncation_reason הוא byte_budget. ממשיכים ב-lines=[end + 1, ...], ושום שורה אינה חוזרת פעמיים או הולכת לאיבוד. truncated בלי סיבה הוא עדיין השורה שמעל: end שביקשו מעבר לסוף הקובץ. שורה אחת שגם לבדה אינה נכנסת — answer_too_large, עם hint שמפנה לחיפוש, שמחזיר אותה כקטע חסום. ראו תקרת התשובה — מה כלי מצהיר, ומי אוכף

start מעבר לסוף הקובץ

{"ok": false, "error": "range_out_of_bounds"}. קיצוץ כאן היה מחזיר קטע ריק שנראה כמו תשובה תקינה

start > end, אפס, שלילי, או רשימה שאינה בת שני איברים

{"ok": false, "error": "invalid_line_range"}

ערך שאינו מספר שלם (מחרוזת, שבר, בוליאני)

נדחה כבר בסכימה, לפני שהכלי רץ

הערה

הדחייה של בוליאני אינה קוסמטית. 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) — כי מי שלא יודע שאפשר להחריג תיקייה לא יחפש את זה בתיאור של הפרמטר.

תבנית

מה היא תופסת

docs

כל מה שתחת התיקייה, בכל עומק

*.html

קבצי .html בכל עומק — הכוכבית חוצה תיקיות

docs/*.html

קבצי .html בכל עומק תחת docs, כולל אלה שיושבים בה ישירות

docs/**/*.html

מחמיץ את הקבצים שיושבים ישירות בתיקייה, בלי שגיאה. ** אינו מיוחד בלי :(glob), והלוכסן שאחריו דורש תיקייה נוספת. זו הצורה שסוכנים כותבים מתוך הרגל של .gitignore, ולכן התיאור מזהיר ממנה במפורש

:(exclude)assets/vendor

הכול חוץ מהתיקייה

:(icase)*.html

כמו *.html, בלי רגישות לרישיות

  • מהשורש. 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 בלבד, בלי פענוח ובלי קיפול שורות. לכן &amp; מוצא את &amp; שמשתמש הקליד, ולא את &. מחט שיש בה 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.

מצבי הקצה

קלט

התנהגות

query יחד עם lines

{"ok": false, "error": "query_and_lines"}. שני מצבי קריאה שאינם מצטברים, בדיוק כמו outline_and_lines — ולא התעלמות שקטה מאחד מהם

query שיש בו שורה חדשה

{"ok": false, "error": "query_multiline"}. ההתאמה נעשית שורה אחר שורה, ולכן שאילתה רב-שורתית אינה יכולה להתאים לעולם — ואפס מופעים כאן הוא הצלחה. בלי הדחייה, ביטוי שנמצא בקובץ היה מקבל ”לא נמצא“ שנראה בטוח. זו דחייה ולא תמיכה, כי כל רשומה עוגנת ל-line אחד, ולהתאמה שחוצה שורות אין line מוגדר

אפס מופעים

הצלחה, עם results ריק ו-total: 0. ”לא נמצא“ ו“לא נתמך“ הם שני מצבים שונים, ומי שמסתעף על כשל לא יתייחס לקובץ תקין ככזה

query ריק או רווחים בלבד

{"ok": false, "error": "query_too_short"} — אותו קוד ש-codekeeper_search_repo כבר מחזיר. הוא אינו נקרא כ“בלי query“: מחרוזת ריקה מתאימה לכל מיקום בקובץ, וקריאתה כהיעדר הייתה מחזירה את הקובץ המלא למי שביקש מופעים

query באורך תו אחד

מתקבל, ומחזיר מופעים. codekeeper_search_repo דוחה שאילתה קצרה משני תווים עם אותו קוד query_too_short, וההפרש מכוון: קובץ בודד אינו ריפו שלם. תו אחד על פני ריפו שלם הוא סריקה חסרת תוחלת, ובקובץ אחד הוא שאלה סבירה שהתשובה עליה חסומה ממילא בתקרת המופעים

יותר מופעים מהתקרה

truncated: true, ו-total אומר כמה יש באמת. אין חיתוך שקט

context_lines או max_results בלי query

{"ok": false, "error": "context_lines_without_query"}, או {"ok": false, "error": "max_results_without_query"}. שני הפרמטרים מתארים איך להציג מופעים, ובלי query אין מופעים. זו אותה הכרעה שבשורה הראשונה של הטבלה: פרמטר שהתקבל ונזרק הוא אותה התעלמות שקטה בדיוק. שני קודים ולא אחד, כדי שהקורא ידע איזה נדחה

max_results או context_lines מחוץ לטווח

נצמדים, ואינם נדחים — אותה מדיניות מוצהרת של שאר פרמטרי העימוד בשרת הזה, ראו הקבועים, התקרות ואוצר המילים של התשובה. ההצמדה חלה רק כשהועבר query; בלעדיו הפרמטר נדחה לגמרי, ראו השורה הקודמת

חשוב

התקרה על טקסט הרשומה נמדדת בבתים, לא בתווים. המספר נלקח מ- 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 הטקסט עדיין מנורמל.

מצבי הקצה

קלט

התנהגות

toc או section יחד עם מצב קריאה אחר

כל זוג נדחה בקוד משלו: toc_and_section, toc_and_query, toc_and_lines, section_and_query ו-section_and_lines — ו-query_and_lines כמו קודם. ארבעת מצבי הקריאה אינם מצטברים, וקוד לכל זוג אומר לקורא איזה שניים מהפרמטרים שלו התנגשו. בקשה שנוקבת בשלושה מקבלת את הזוג הראשון לפי הסדר הזה, ותמיד אותו אחד. הסירוב קורה לפני שנוגעים במסד, וגם כשלא נקבו בשום קובץ

max_chars או offset בלי section

max_chars_without_section או offset_without_section — גם יחד עם toc=true, כי המפה אינה מעומדת. אותה הכרעה כמו context_lines_without_query: פרמטר שהתקבל ונזרק הוא התעלמות שקטה. offset=0 הוא ערך ולא ”לא נשלח“, ולכן גם הוא נדחה

max_depth בלי toc ובלי section

max_depth_without_toc — גם יחד עם lines או query, כי בתשובה שלהם אין מפה להגביל. עם section הוא תקף: הוא מגביל את המפה שב-section_not_found. אותה הכרעה כמו max_chars_without_section, ושני אלה נבדקים לפניו — בקשה שנושאת גם max_chars בלי section מקבלת max_chars_without_section

max_depth שאינו מספר שלם, או מחוץ לטווח

StrictInt, כמו context_lines ובניגוד ל-max_chars: "2", 2.0 ו-true נדחים בסכימה, בשגיאת ולידציה, ואינם מומרים. מספר שלם מחוץ לטווח נצמד ואינו נדחה — 0 ושלילי הם 1 — ותשובה שנושאת מפה מהדהדת את מה שנאכף

section ריק או רווחים בלבד

empty_section, עם hint שמפנה ל-toc=true. רווחים בלבד שארוכים מ-4,096 תווים הם section_too_long — קודם אורך, אחר כך תוכן, כמו בכלי התיעוד

toc כמחרוזת או כמספר

toc הוא bool רגיל, כמו outline: "true", "yes", "1" ו-1 הם המפה, ו-"false", "no", "0" ו-0 הם הקובץ המלא — אותה תשובה בדיוק כמו true ו-false. רק מה שאינו בוליאני בשום קריאה נדחה בסכימה, בשגיאת ולידציה: "maybe", 2, null, "". למה לא strict, אף ש-lines כן: המחרוזת "true" מגיעה לכלי כמו שנשלחה — ה-SDK מפענח אותה ל-true, וזורק את הפענוח כי בוליאני הוא מספר בפייתון (pre_parse_json ב-mcp 1.28.1). ב-lines ה-strict מונע קריאה שגויה — [true, 5] שנקרא כשורות 1 עד 5 — אבל בדגל ההמרה נותנת בדיוק את מה שהלקוח התכוון אליו, ולכן strict רק חוסם לקוח ששולח דגלים כמחרוזות. עד #3472 toc היה strict, וזה בדיוק מה שקרה: outline="true" עבד, ו-toc="true" נדחה. מאחורי הסכימה יש רשת לקורא שאינו עובר בה — invalid_toc על כל מה שאינו bool (בלי המרה), ו-invalid_section ל-section שאינו מחרוזת

קובץ שאינו Markdown

not_markdown, עם file ו-hint

קובץ מעל התקרה

too_large_for_sections, עם bytes, max, file ו-hint

קובץ שהפרסר מסרב לו

אותו קוד ואותם שדות כמו בכלי התיעוד, ובמקום ההקשר של הריפו — file ו-hint

כותרת שאינה בקובץ, או כותרת כפולה

section_not_found עם מפה והצעות, או ambiguous_section עם מועמדים — כמו בכלי התיעוד, עם file ובלי hint: הקובץ נקרא, ומה שלא נמצא הוא השאלה. כותרת ששמה המלא חוזר אינה נקראת בשמה: lines על ה-line_range של המועמד שלה מחזיר בדיוק את טקסט הסעיף

section ארוך מ-4,096 תווים

section_too_long, עם max_chars ו-actual_chars — ראו איך docs_get_section מוצא כותרת

עמוד שאינו נכנס בתקציב הבתים (תווים רחבים, max_chars גבוה)

העמוד נגמר מוקדם, על גבול תו: truncated הוא true, next_offset הוא איפה שנגמר, ו-truncation_reason הוא byte_budget. ממשיכים מ-next_offset, ושום תו אינו הולך לאיבוד

סעיף עם יותר תת-סעיפים מהתקרה של המפה

subsections נחתכת ב-_TOC_MAX, בסדר המסמך, עם subsections_truncated: true

כותרות שגדולות מהתקציב לבדן (גם עמוד ריק אינו נכנס)

section_too_large, עם bytes, max, line_range, file ו-hint — קוראים את הסעיף ב-lines לפי line_range. max הוא OUTPUT_BYTE_BUDGET, ו-bytes הוא מה שהעמוד הקטן ביותר שנושא תוכן — תו אחד — היה צריך, עם found ו-status; זה המספר שהושווה לתקציב, ולכן bytes > max

מפה שאינה נכנסת בתקציב הבתים (כותרות רבות, ארוכות או מקוננות עמוק)

המפה נחתכת מהסוף עד שהתשובה נכנסת, עם toc_truncated: true — אותו דגל של התקרה _TOC_MAX, בסיבה שנייה. section_count נשאר מספר הכותרות כולן. כותרת שנחתכה נקראת בשמה כשאין עוד כותרת באותו שם, ו-query מוצא את השורות שמחזיקות את הטקסט שלה

section_not_found או ambiguous_section שאינם נכנסים

ב-section_not_found המפה נחתכת קודם, וההצעות רק אחריה (suggestions_truncated). ב-ambiguous_section המועמדים נחתכים מהסוף, בסדר המסמך (candidates_truncated) — מועמד שחסר מופיע במפה, עם line_range ועם השם המלא, והשם המלא מגיע אליו רק כשאין עוד כותרת באותו שם. וכשגם המפה נחתכה, query מוצא אותו

תשובה שגם כשכל הרשימות שלה ריקות אינה נכנסת

answer_too_large, עם bytes, max, file ו-hint — max הוא OUTPUT_BYTE_BUDGET, ו-bytes הוא גודל התשובה כשכל הרשימות ריקות, עם המקום ל-found ול-status. מה שנשאר בה הוא המטא-דאטה של הקובץ: תיאור ארוך, או — במפה — תגיות שגדולות מהתקציב לבדן. הסירוב נושא את file כמו כל תשובה, ומוותר על התגיות ועל התיאור כמו כל סירוב (השורה הבאה). כשהתגיות או התיאור הם מה שלא נכנס, הכלי אינו מחזיר את הסירוב אלא את המפה או הסעיף בלעדיהם, עם הסימנים ועם ההפניה ל-codekeeper_update_file_description (ראו תקרת התשובה — מה כלי מצהיר, ומי אוכף); והשורש, כותבים שאינם אוכפים תקרה על תגיות ועל תיאור, ב-#3489

סירוב שנושא file ואינו נכנס בתקציב (תגיות ענקיות, תיאור ענק)

הסירוב מוותר על file.tags מהסוף, עם file.tags_truncated: true, עד שהוא נכנס — ואם גם בלי תגיות אינו נכנס, התיאור יורד שלם ובמקומו file.description_bytes, מספר בתי ה-UTF-8 שלו. השדה הזה מופיע רק כשהתיאור ירד, ולכן הוא הסימן; תיאור אינו נחתך באמצע, כי תיאור חתוך נקרא כתיאור שלם. כך בכל סירוב שנושא file: not_markdown, too_large_for_sections, סירובי הפרסר, section_too_long, section_too_large, answer_too_large — וגם הסירובים של קריאה מלאה, טווח ו-query. עד #3460 התיאור נשאר, וסירוב עם תיאור של 200,000 תווים עבריים יצא 400,523 בתים. ב-section_not_found וב-ambiguous_section התגיות נחתכות ראשונות, לפני המפה, ההצעות והמועמדים — מטא-דאטה שווה פחות מהתשובה לשאלה. תשובה מוצלחת מוותרת על המטא-דאטה רק כשהיא מה שעומד בינה לבין התקציב, או כשהיא גדולה מכל שאר תשובה שנחתכה (ראו תקרת התשובה — מה כלי מצהיר, ומי אוכף): מפה או סעיף שנכנסים מחזירים את התגיות שלמות, ומה שנחתך כדי להיכנס הוא המפה או העמוד — אלא אם התגיות או התיאור לבדם הם מה שלא נכנס. והתגיות עצמן שמורות כמו שהן — רק התשובה הזו לא נשאה אותן. כל תשובה שהתיאור ירד ממנה מפנה ב-hint ל-codekeeper_update_file_description. עד #3472 הסירוב נשא אותן שלמות, ותגית ענקית שלחה סירוב גדול מהתקציב בלי שום סימן

section="null" או query="null", מ-claude.ai או מ-Claude Code

הקובץ המלא חוזר, ולא סעיף, חיפוש או סירוב. הכלי מקבל את המחרוזת "null" כמו שנשלחה (#3471), אבל שני הלקוחות האלה אינם שולחים אותה: בפרמטר שהסכימה שלו מתירה null — כמו section ו-query כאן — הם ממירים את המילה null ל-null של JSON עוד לפני שהבקשה יוצאת, ומה שמגיע הוא ”לא נשלח“. השרת אינו יכול להבחין בין השניים. נמדד ב-2026-09-29 מול claude-code 2.1.284 ו-Anthropic/ClaudeAI 1.0.0 — ב-Claude Code על שני הפרמטרים, וב-claude.ai על query (ראו מצב הקריאה (ck_read_mode)). כותרת ששמה בדיוק null נקראת בטווח שלה: toc=true, ואז lines לפי ה-line_range שלה

איפה המפה חולקת על הוובאפ

המפה היא של 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 — ולכן אינם נראים שם כלל.