שרת ה-MCP — חיבור Claude ל-CodeKeeper

summary:

שרת ה-MCP שחושף את CodeKeeper ל-Claude: הכלים, האימות וההרשאות, פריימר הסוכן, עריכה מהדפדפן, ההתראה כשסוכן שומר קובץ, מדידת השימוש ושער הפרטיות שלה, וההפעלה צעד אחר צעד מול Claude.ai ומול Claude Code.

שרת MCP (Model Context Protocol) שחושף את CodeKeeper ל-Claude: הקבצים והאוספים האישיים של כל משתמש, ולאדמין — גם דפדפן הריפו מעל ה-Repo Sync Engine. עובד גם מול Claude.ai (Custom Connector דרך OAuth 2.1) וגם מול Claude Code / Claude Desktop (טוקן אישי).

קוד: mcp_server/ · תכנון מלא: FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md

מה זה נותן

  • Claude קורא ומחפש בקבצים השמורים שלך ישירות — בלי העתק-הדבק ובלי צילומי מסך.

  • שמירה (יצירה/עדכון) מאחורי הרשאת write מפורשת — עדכון תוכן תמיד יוצר גרסה חדשה (append-only), אף פעם לא דורס. הסייג: codekeeper_update_file_description נוגע במטא-דאטה ולא בתוכן, ולכן הוא כן דורס — את התיאור של הגרסה האחרונה, בלי ליצור גרסה ובלי לשמור את הערך הקודם. ראו עדכון תיאור בלי גרסה חדשה.

  • לאדמין: קריאה וחיפוש בכל הריפואים המשוקפים (תיעוד, קוד, מסמכי תכנון).

ארכיטקטורה בקצרה

Claude.ai / Claude Code
     │  Streamable HTTP (+OAuth 2.1 או Bearer PAT)
     ▼
שירות MCP נפרד (ASGI, uvicorn)  ── import ישיר ──▶  database/ → MongoDB
     │                                              (code_snippets, collections)
     └── דפדפן ריפו (אדמין) ──▶ bare mirrors בדיסק המקומי (REPO_MIRROR_PATH)
  • ה-user_id נגזר תמיד מהטוקן — לעולם לא מקלט הלקוח.

  • מכבד את חוק ה-Smart Projection: רשימות קבצים וחיפוש מחזירים מטא-דאטה בלבד; תוכן מלא רק בבקשה מפורשת לקובץ בודד. החריג הוא כלי רשימת הפתקים (codekeeper_list_notes, codekeeper_list_board_notes, codekeeper_list_repo_notes): פתק הוא ישות קטנה, חסומה ב-MAX_NOTE_CHARS, והרשימה מחזירה את גופו כברירת מחדל (include_content=true); בשני הראשונים include_content=false משאיר את הגוף במסד ומחזיר במקומו את גודלו — ראו קריאת פתק בודד — codekeeper_get_note.

מודל הריצה של הכלים

גופי הכלים נכתבים סינכרוניים, ו-AdminAwareFastMCP.add_tool מעביר כל אחד לחוט עובד בזמן הרישום. זו נקודה אחת שמכסה את כל הכלים, ולא החלטה שחוזרת בכל כלי בנפרד — הכלי הבא נולד נכון בלי שאיש יזכור.

עד #3379 השרת לא עשה את זה, וה-docstring טען שכן. ה-SDK קורא גוף סינכרוני ישירות (return fn(**arguments)), כלומר בקשה איטית אחת עצרה את כל הסשנים בתהליך ולא רק את הקורא שלה. האמונה השגויה היא הסיבה שאיש לא בדק, ולכן הסעיף הזה קיים: תיאור שגוי של מודל ריצה הוא מה שמסתיר את הבאג הבא.

יש שני יעדים, ולא אחד:

  • קריאות הולכות ל-asyncio.to_thread, כלומר למאגר ברירת המחדל של לולאת האירועים — ומאז #3391 המאגר הזה הוא שלנו: attach_read_pool ב-mcp_server/server.py מתקין אותו בעליית ה-lifespan של אפליקציית ה-ASGI, וגודלו נגזר ממכסת הזיכרון של הקונטיינר. כמה קריאות רצות במקביל; כמה בדיוק, ולמה דווקא מהזיכרון — בהמשך הסעיף.

  • כתיבות הולכות למאגר ייעודי בעל worker יחיד. לכן גוף כתיבה אחד רץ בכל רגע, והתור מוסר אותם לעובד לפי סדר ההגעה — queue.SimpleQueue הוא FIFO.

גודל מאגר הקריאות נגזר ממכסת הזיכרון, לא ממספר המעבדים. עד #3391 המאגר היה ברירת המחדל של CPython, min(32, os.cpu_count() + 4), ו-CPython עצמו מתעד ש-os.cpu_count() ”אינו שקול למספר המעבדים שהתהליך הנוכחי יכול להשתמש בהם“: בקונטיינר הוא מדווח את ליבות המארח. שורת הקיבולת בייצור הראתה בדיוק את זה — read pool 12 threads (os.cpu_count=8, usable=8) מול cpu quota cgroup v2: 0.50 cpu, כלומר 12 חוטים על חצי מעבד. לקריאת מונגו או דיסק זה לא מזיק, החוטים ישנים; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן מאגר רחב קונה רק מקביליות בזיכרון — 12 עצי פרסור בו-זמנית, בלי תפוקה. החסם האמיתי הוא הזיכרון, וממנו נגזר הגודל:

(מגבלת הזיכרון − קו הבסיס − שוליים) ÷ עלות פרסור אחד
  • מגבלת הזיכרון נקראת מ-cgroup בזמן ריצה, כמו שמכסת ה-CPU כבר נקראת: memory.max ב-v2 (max פירושו ללא מגבלה) ו-memory/memory.limit_in_bytes ב-v1 (שם ”ללא מגבלה“ נקרא כמספר סמוך ל-LONG_MAX). היא אינה מקובעת בקוד — השירות כבר עבר תוכנית ואזור פעם אחת. הקורא: _memory_limit ב-mcp_server/server.py, לצד _cpu_budget.

  • קו הבסיס (_PROCESS_BASELINE_BYTES, ‏92MiB) הוא ה-RSS של השירות בשקט בייצור, כפי שנמדד במדדי Render ב-2026-09-19; השוליים (_NON_PARSE_MARGIN_BYTES, ‏64MiB) הם הפער בין קו הבסיס לשיאי העבודה שנצפו באותם מדדים ביום שאחריו — כל מה שהתהליך מחזיק ואינו הפרסור המתוקצב.

  • עלות פרסור אחד היא _PARSE_RSS_PER_INPUT_BYTE כפול הקלט הגדול ביותר שפרסור יכול לקבל, MAX_FILE_SIZE_FOR_DISPLAY: ‏``codekeeper_docs_get_section`` קורא דרך RepoBackend.get_file בלי lines, ולכן בלי max_size, ודוחה too_large לפני שהוא מפרסר. הקבוע מיובא ולא מועתק, כדי שהתקציב יזוז עם התקרה. הקבוע נמדד על services.md_parser (‏``markdown-it-py`` הנעוץ) — היקר מבין שני הפרסרים שהכלי מריץ: codekeeper_docs_get_section מריץ services.rst_parser על docs/*.rst ו-services.md_parser על *.md, ומסלול ה-RST זול בהרבה. כל המספרים כאן נמדדו מחדש ב-2026-09-27 בשיטה המתוקנת — איפוס VmHWM רגע לפני הפרסור, 40 סידורי זיכרון לכל מדידה, וחסם עליון שהוא המקסימום ועוד פיגור המונים של הקרנל (השיטה: סקריפטים שימושיים). קורפוס ה-RST של הריפו משוכפל עד תקרת הקריאה מגיע ל-2.0 בתים לכל בית קלט (השיטה שלפני האיפוס לא הראתה ממנו כלום — השיא שלפני הפרסור הסתיר אותו), העמוד הצפוף ביותר (docs/modules/index.rst) משוכפל עד התקרה ל-7.0, וצורה עוינת של כותרת בת תו אחד בכל שורה ל-90.8 בלי תקרה — 44.3MiB לפרסור אחד, מעל 35.2MiB שהנוסחה מקצה לחוט. לכן יש ל-RST תקרת סקשנים מאז הסקירה של #3429 — היום MAX_SECTIONS (50,000), ברירת המחדל של שני הפרסרים, שהכלי אינו דורס — ו-too_many_sections חוזר במקום פרסור של הקובץ כולו: אותה צורה עוינת נעצרת ב-20.2MiB (41.4 בתים לכל בית קלט), בתוך ההקצאה, ואף עמוד אמיתי אינו מתקרב לתקרה (ראו איך docs_get_section מוצא כותרת). העלות לכל בית קלט היא שיא ה-RSS בזמן הפרסור — מה שכמה פרסורים במקביל מחזיקים יחד — והיא נגזרת מ**צפיפות** המסמך ולא מגודלו: משוכפלים עד תקרת הקריאה, קורפוס ה-Markdown של הריפו מגיע ל-24.6 בתים לכל בית קלט, והמסמך הצפוף ביותר בו (CLOUD.md, כ-170 טוקנים ל-KB) ל-72.4 — 73.2 כחסם עליון (על 3.0.0, באותו סקריפט: 24.7 ו-72.8, חסם 73.6 — כלומר השדרוג ל-4.2.0 אינו מזיז את העלות). הצורה הזו עולה יותר מהקבוע, ותמיד עלתה: 72 נקבע ב-#3429 ממדידה אחת שלה (71.7) מעוגלת כלפי מעלה; על 40 סידורי זיכרון אותה צורה על הפרסר של אז (3.0.0) נעה בין 71.2 ל-72.8, כלומר 71.7 היה הגרלה, והשיטה שבאה אחריה — שבה השיא שלפני הפרסור הסתיר כחצי MB — קראה אותה נמוך עוד יותר (70.1 ו-71.2). הקבוע מחזיק רק בזכות התקרות של #3391 (#3467): הכלי דוחה את שתי הצורות המשוכפלות בתקרת השורות לפני הפרסור, ולכן הסקריפט חותך כל קלט למה שהכלי עוד מפרסר, ומה שמכריע את הקבוע הוא הקלט העוין. ומאז #3391 הקבוע חוסם גם את הקלט העוין — בתוך התקרות של הפרסר. בלי תקרות, קובץ עוין של שורות-תבליט בודדות עולה כ-292 בתים לבית, כלומר כ-142.5MiB לפרסור אחד, מה ששום רוחב מאגר אינו סופג; ותקרת הכותרות אינה נוגעת בו, כי אין בו אף כותרת. md_parser מסרב לקובץ ארוך מ-MAX_LINES לפני הפרסור ועוצר פרסור ב-MAX_TOKENS, והקלט הגרוע שנמצא תחת שתיהן נכנס ב-_PARSE_COST_BYTES: החסם העליון שלו הוא 69.02 בתים לכל בית של תקרת הקריאה — 95.9% מהקבוע, מרווח של 4.1% בלבד. זה לא הממוצע ולא ריצה טיפוסית: השיא הגבוה ביותר שנמדד על פני הסידורים (34,959,360 בתים; הפיזור 92.2%–94.8% מ-_PARSE_COST_BYTES, כך שמי שמריץ פעם אחת ומקבל פחות פגש סידור ולא שינוי), ועוד פיגור המונים של הקרנל. מדידה עתידית שעוברת את 72 אינה רעש — החסם כבר כולל את שניהם, ולכן מעבר שלו פירושו שהפרסור התייקר. איך החסם נבנה והפיזור המלא — ליד _PARSE_RSS_PER_INPUT_BYTE; הקלט — ליד MAX_TOKENS ב- services/md_parser.py; ואיך נבחרו שני המספרים — ב- איזה קובץ docs_get_section קורא, ובאיזה ריפו. ‏``scripts/measure_md_parse_cost.py`` מודד את המספרים מחדש לשני הפרסרים, כולל הקלט העוין כמו שהכלי מקבל אותו, ויוצא בקוד 1 כשמשהו כבר אינו נכנס — מריצים אותו כשאחד מהם משתנה.

  • ההחזקה הגדולה ביותר של חוט קריאה אינה הפרסור, וזו החלטה מתועדת. כלי האדמין codekeeper_get_repo_file עם outline=true או lines= קורא עד RANGE_READ_MAX_BYTES (‏10MiB), והמראה טוענת blob שמתחת לתקרה כולו לזיכרון לפני הפענוח והחיתוך (blob שמעליה נדחה מ-git cat-file -s בלי להיקרא — ראו #3433 למטה). נמדד מחדש ב-2026-09-27, עם האיפוס ועל 40 סידורי זיכרון (בסוגריים — החסם העליון), ברתמה שאינה בריפו, כי הסקריפט מודד רק את הפרסור: outline של קובץ RST בגודל 10MB מחזיק לאורך המסלול כולו 80.8MiB (81.2) בשכפול ריאליסטי ו-91.6MiB (92.0) עם כותרות עוינות. הפרסור לבדו הוא 61.2MiB (שורת outline_densest_real של הסקריפט), והשאר הוא הטקסט של 10MB והקריאה שהביאה אותו; השיטה שלפני האיפוס שמה את הפרסור לבדו על 51MiB, כי חוצץ הקריאה של 10MB הסתיר את ההפרש. קריאת lines= של webapp/static/js/md_preview.bundle.js האמיתי (7MB) מחזיקה 29.6MiB (29.9). כלומר ההחזקה הגדולה ביותר מגיעה עד פי 2.6 מ-35.2MiB שהמחלק מקצה לחוט. המחלק נשאר מתומחר לפי הפרסור בכוונה: הפרסור הוא מה שהכלי הציבורי יכול לעלות בכל רגע; קריאות הטווח הן אדמין בלבד; ההחזקה הגדולה ביותר על קובץ שקיים היום במראות — קריאת הבאנדל — קטנה מההקצאה של חוט אחד, ולכן נכנסת גם ברוחב מלא; וה-81–92MiB דורשים קובץ RST של 10MB, שאין באף ריפו משוקף (הגדול ביותר כאן, docs/mcp-server.rst, קטן מ-250KB ב-2026-09-27). תמחור לפי הצורה הזו היה מצמצם את המסלול הציבורי ל-3 חוטים (לפי 92MiB) בגלל צורה שאין לה מקור. מה שכן השתנה בגלל זה הוא התקרה (למטה); והשורש — לחסום את הקריאה במקור — נסגר ב-#3433: get_file_at_commit שואל את מאגר האובייקטים לגודל (git cat-file -s <sha>:<path>) לפני git show, ולכן max_size הוא חסם על מה שנקרא ולא בדיקה בדיעבד. נמדד מחדש (אותו יום, אותה שיטה): קובץ של 12MB שנדחה עלה 22MiB שיא לפני, ואפס אחרי; קובץ של 7MB שמתחת לתקרה עולה 14MiB לפני ואחרי, כי אותו כן קוראים.

  • רצפה (_READ_POOL_FLOOR, ‏2): כדי שקריאת מונגו תקועה אחת לא תשבית את השאר. שניים הוא הגודל הקטן ביותר עם התכונה הזאת, וכל יחידה מעליו היא זיכרון שהחשבון אמר שאין. תקרה (_READ_POOL_CAP, ‏12): הרוחב שהייצור כבר הריץ לפני #3391. מעליה עוד חוטים הם רק עוד פרסורים מקבילים תחת ה-GIL, ומעבר לתוכנית גדולה לא ירחיב את המאגר — על בסיס מחלק שמתאר את הפרסור ולא את קריאות הטווח — בלי שאיש החליט. הגרסה הראשונה של ה-PR נקבה ב-32, התקרה ש-CPython בחר ל-executor ברירת המחדל שלו; הסקירה של #3429 הורידה אותה.

  • כשאין מגבלה שאפשר לקרוא — הקובץ חסר, אינו ניתן לפענוח, או אומר ”ללא מגבלה“ — הגודל הוא הרצפה, ולא cpu_count + 4, המספר שהגזירה הזאת באה להפסיק להישען עליו. הקצה השמרני נבחר בכוונה: מאגר צר מדי הוא איטיות גלויה, ושורת הקיבולת אומרת למה; מאגר רחב מדי הוא OOM שאיש לא מייחס. ה-fallback נרשם גם כ-WARNING נפרד, כי מסלול גרוע שאיש לא מדווח עליו הוא זה שנשאר.

על תוכנית הייצור (0.5c-512mb): ‏(512MiB − 92MiB − 64MiB) ÷ 35.2MiB = 10 חוטים, במקום 12. ‏``_read_pool_size`` היא הפונקציה הטהורה שעושה את החשבון, וכל אחד מהתנאים — הגזירה, הרצפה, התקרה וה-fallback — מעוגן בטסט למספר שחושב ביד, כך שמימוש שמחזיר קבוע נופל.

איפה ההתקנה קורית, ולמה שם. ‏``loop.set_default_executor`` דורש לולאה רצה ומקבל רק ThreadPoolExecutor, ו-build_app רץ בזמן ייבוא, לפני שיש ל-uvicorn לולאה. ‏``FastMCP.streamable_http_app()`` בונה את אפליקציית Starlette עם lifespan= משלה, ולכן on_startup אינו רץ כלל — עטיפת ה-lifespan היא ה-seam היחיד, אותו seam שניקוז ה-PostHog כבר משתמש בו. המאגר נבנה שלם ומתפרסם בהצבה אחת. הוא אינו נסגר ביציאה מה-lifespan: ה-executor ברירת המחדל שייך ללולאה, ו-asyncio.run של uvicorn סוגר אותו ב-shutdown_default_executor עם המתנה — קריאה שבאמצע מסתיימת בדיפלוי, כמו כתיבה. ומכיוון שהשירות רץ כתהליך אחד (uvicorn בלי --workers; בלוגים Started server process פעם אחת לכל מופע), המאגר הזה הוא כל מקביליות הקריאה של השירות.

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

contextvars מועתקים במפורש בדרך לכתיבה. asyncio.to_thread מעתיק את ההקשר בעצמו, ו-loop.run_in_executor אינו מעתיק — ובלי ההעתקה get_access_token() מחזיר None בתוך העובד, כלומר require_write ו- require_admin מפסיקים לראות מי שואל.

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

הרמה נבחרה במודע: info ולא debug. הרמה שבתוקף בשירות היא INFO, כך ש-debug אינו שקט אלא נעדר — וזו בדיוק התקלה שהשורות האלה נולדו ממנה. הנפח אינו יכול להתפרץ: worker יחיד מריץ כתיבה אחת בכל רגע וגוף כתיבה נמדד ב-4.3–4.9 שניות ב-p50, כלומר השורה חסומה בסביבות שלוש-עשרה לדקה תהיה התנועה אשר תהיה. ורישום של המקרה האיטי בלבד היה אומר מתי ההמתנה חריגה בלי לומר לעולם מהי ההמתנה הרגילה — המספר שכל החלטה על רוחב התור נשענת עליו.

ומה שהופך את השורות לנראות בכלל: תהליך ה-MCP לא הגדיר לוגים בעצמו, ומה שכן הגדיר אותם הוא FastMCP.__init__ שמסתיים ב-configure_logging. לכן שורה שנרשמה לפני בניית שרת ה-MCP נזרקה במקום. שורת הקיבולת נרשמת מתוך ה-lifespan, מיד אחרי שמאגר הקריאות הותקן — מאוחר עוד יותר מ-build_mcp — ומהמאגר שהותקן בפועל: היא קוראת את _max_workers שלו ואינה מחשבת מספר בנפרד, כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ‏``mcp_server/app.py`` מגדיר לוגים כבר בייבוא (‏``LOG_LEVEL``, ברירת מחדל INFO) כדי שגם רשומות מוקדמות יותר ייראו.

ביטול, כפי שנמדד: בקשת כתיבה שבוטלה בזמן שהיא עדיין בתור אינה רצה כלל. בקשה שבוטלה אחרי שהגוף התחיל רצה עד הסוף, כי גוף סינכרוני אינו ניתן להפסקה — הקורא מפסיק להמתין, הכתיבה קורית. לקוח שעבר timeout וניסה שוב מייצר אפוא כתיבה שנייה, לא החלפה של הראשונה.

כלי כתיבה אינו יכול להיות async def. מאגר בעל worker יחיד מסדר בטור גופים סינכרוניים; גוף אסינכרוני היה מחזיר לעובד קורוטינה ורץ על הלולאה בלי תור ובלי סדר, בזמן שהכלי עדיין מוצהר ככתיבה. הרישום נכשל במפורש במקרה הזה, כי זו הנקודה היחידה שבה אפשר לראות את זה.

הכלים

כלי משתמש (לכל משתמש מחובר):

כלי

תיאור

codekeeper_list_files

רשימת קבצים (מטא-דאטה, עם עימוד)

codekeeper_search_code

חיפוש טקסט מלא ($text של מונגו) על שם הקובץ, התיאור, התגיות ותוכן הקוד → מטא-דאטה של הקבצים התואמים, בלי התוכן. מתאים למילים שלמות; ראו את ההערה מתחת לטבלה

codekeeper_get_file

תוכן מלא של קובץ (לפי שם/מזהה, אופציונלית גרסה). lines=[start, end] מחזיר רק את הטווח המבוקש — ראו קריאת טווח שורות. query="..." מחזיר את המופעים של המחרוזת בקובץ במקום את התוכן, בצורת התשובה של codekeeper_search_repo — ראו חיפוש בתוך קובץ שמור (query)

codekeeper_save_file

כתיבה: יצירת קובץ חדש. שם שכבר תפוס נדחה ב-file_exists, ולעדכון קובץ קיים יש codekeeper_edit_file / codekeeper_append_file. בכפוף למגבלת MAX_CODE_SIZE — ברירת מחדל 100K תווים, ניתנת להגדלה בקונפיג. התוכן נשמר בדיוק כמו שנשלח, בכל שלושת כלי הכתיבה: בלי ניקוי תווים ובלי קיצוץ רווחים או newline — ראו ניקוי קוד מודבק (Pasted-code cleanup). דורש write

codekeeper_edit_file

כתיבה: מצא-והחלף מדויק בקובץ קיים (old_string → new_string) — בלי לשלוח את כל הקובץ. נשמר כגרסה חדשה, ורק הטקסט שהוחלף משתנה: שאר הקובץ נשאר תו בתו. דורש write

codekeeper_append_file

כתיבה: הוספת טקסט לסוף קובץ קיים (גרסה חדשה). דורש write

codekeeper_update_file_description

כתיבה: החלפת ה-description של קובץ קיים, בלי לשלוח את התוכן ובלי לגעת בו. אינו יוצר גרסה — ראו עדכון תיאור בלי גרסה חדשה. תיאור בלבד; תגיות אינן משתנות. דורש write

codekeeper_list_versions

היסטוריית גרסאות של קובץ. version_created_at הוא מתי כל גרסה נשמרה, ולא updated_at — זה שדה של הקובץ ובר-שינוי, ולכן שורת היסטוריה שנשענת עליו יכולה לתאר רגע מאוחר יותר. ראו docs/database/detailed-schema.rst

codekeeper_list_notes

פתקים דביקים של קובץ (לפי file_name) — אותם פתקים שמוצגים ב-UI של הוובאפ. include_content=false מחזיר את השורות בלי הגוף, עם גודלו — ראו קריאת פתק בודד — codekeeper_get_note

codekeeper_get_note

פתק בודד לפי note_id: הגוף הנוכחי בדיוק כפי שהוא מאוחסן, הכותרת, הצבע, version — מספר הגוף הנוכחי (null לגוף ריק), שנקרא באותה קריאה תחומה כמו הגוף — ואיפה הפתק יושב, בדיוק בארגומנטים שכלי הרשימה המתאים דורש. פתק של משתמש אחר, ופתק ריפו למי שאינו אדמין — not_found; פתק שנערך ממש ברגע הקריאה — conflict. ראו קריאת פתק בודד — codekeeper_get_note

codekeeper_create_note

כתיבה: יצירת פתק דביק על קובץ קיים; line אופציונלי מעגן לשורת מקור (בלעדיו הפתק צף). דורש write

codekeeper_update_note

כתיבה: עדכון חלקי של פתק לפי note_id (תוכן/שורה/צבע/מוזער) — דורס במקום, אבל התוכן הקודם נשמר כגרסה. דורש write

codekeeper_note_str_replace

כתיבה: מצא-והחלף מדויק בתוך פתק (old_string → new_string) — בלי לשלוח את כל גוף הפתק. אותה סמנטיקה ואותם נוסחי שגיאה כמו codekeeper_edit_file, כולל סירוב על התאמה מרובה. דורש write

codekeeper_list_note_versions

הגרסאות הקודמות של פתק (מטא-דאטה בלבד: מספר, זמן, ואורך בתווים — לא בבתים כמו content_bytes של הרשימה הרזה), החדשה תחילה

codekeeper_get_note_version

תוכן של גרסה קודמת אחת. הגוף הנוכחי הוא codekeeper_get_note, שגם אומר באיזה מספר הגוף הזה ייקרא כאן כשיידרס דרך השרת הזה (עריכה מהוובאפ אינה נכנסת להיסטוריה). השחזור עצמו הוא codekeeper_update_note עם התוכן שנקרא

codekeeper_list_boards

הלוחות של המשתמש — משטחים שנושאים פתקים שאינם שייכים לשום קובץ. מייצר את לוח ברירת המחדל בקריאה הראשונה

codekeeper_list_board_notes

הפתקים שעל לוח יחיד (לפי board_id מתוך codekeeper_list_boards). include_content=false מחזיר את השורות בלי הגוף, עם גודלו — הזרימה ללוח גדול: רשימה קטנה ואחריה codekeeper_get_note. ראו קריאת פתק בודד — codekeeper_get_note

codekeeper_create_board_note

כתיבה: פתק חדש על לוח — בלי קובץ. mode הוא surface (יושב על הלוח, ברירת מחדל) או screen (צף מול המסך). title אופציונלי, וייחודי בתוך הלוח. דורש write

codekeeper_search_notes

חיפוש פתק בשלושת המקומות שפתק יכול לשבת בהם — קובץ, לוח, או קובץ בריפו משוקף. לפי שם כברירת מחדל; search_content=true מרחיב גם לגוף הפתק. התאמה על חלק מהמחרוזת, בלי תלות ברישיות. כל פגיעה אומרת איפה הפתק יושב, בדיוק בארגומנטים שכלי הרשימה המתאים דורש — ולעולם אינה נושאת תוכן; את הפתק קוראים ב-codekeeper_get_note לפי המזהה שבפגיעה. ראו את ההערה מתחת לטבלה

codekeeper_list_collections / codekeeper_get_collection / codekeeper_get_collection_items

האוספים והקבצים שבתוכם

codekeeper_add_to_collection

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

codekeeper_docs_get_section

סקשן בודד מקובץ תיעוד — RST או Markdown, לפי הריפו — במקום קובץ שלם. בלי section — עץ הכותרות של העמוד. מחזיר גם breadcrumb, תת-סקשנים ושכנים לניווט. קובץ שלם: section עם הכותרת הראשונה בעץ מחזיר את תת-העץ שלה (תת-הסקשנים כלולים כברירת מחדל) — כל הקובץ כשהוא כולו תחת כותרת עליונה אחת, כמו קובצי bugbot-rules — מדופדף כמו כל סעיף: כל עוד truncated הוא true ממשיכים מ-next_offset. או, לאדמין, codekeeper_get_repo_file. עדיף על codekeeper_get_repo_file לקריאת תיעוד לפי סעיפים. איזה ריפו מגיש אילו נתיבים ובאיזה פורמט — ב-איזה קובץ docs_get_section קורא, ובאיזה ריפו; איך נמצאת כותרת, כולל קיצור המזהה (K11) וכותרות שנושאות בקטיקים — ב-איך docs_get_section מוצא כותרת

הערה

שני החיפושים אינם עובדים אותו דבר, ולא במקרה.

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

search_content=true מוסיף פרדיקט על גוף הפתק. הדגל קיים בגלל פתק בלי שם: רוב הפתקים בפועל נכתבים בלי כותרת, ולכן היו בלתי-נראים לחיפוש לחלוטין — לא ”קשים למציאה“, אלא בלתי-ניתנים למציאה. הוא כבוי כברירת מחדל כי אין ולא יהיה עליו אינדקס: חיפוש בגוף היה מחייב אינדקס טקסט שני על sticky_notes, ומונגו מתיר אינדקס טקסט אחד לכל אוסף — החלטה חד-כיוונית שלא נשרפה כאן. מה שכן מגן: user_id נשאר פרדיקט ראשון, ולכן הסריקה חסומה למכסת המשתמש (1000 פתקים) ואינה COLLSCAN.

הפגיעה עדיין לא נושאת תוכן. הפרויקציה מנתקת את עלות ההעברה מגודל הפתק (עד MAX_NOTE_CHARS תווים), ולכן גם פגיעת-תוכן מחזירה ”איפה“ ולא ”מה“; את הפתק קוראים ב-codekeeper_get_note לפי המזהה שבפגיעה — לא בכלי הרשימה, שמחזיר את המשטח כולו כדי להגיע לפתק אחד (ראו קריאת פתק בודד — codekeeper_get_note). הסינון עצמו כן קורא את הגוף בשרת — זו העלות שהדגל קונה.

מלכודת בנתונים היסטוריים: פתקים ישנים נשמרו עם ישויות HTML (&quot; במקום "); הניקוי רץ רק על כתיבה חדשה. חיפוש תוכן על תו כזה יפספס אותם. זו מגבלת נתונים, לא של השאילתה. ובקריאה: כלי הרשימה מפענחים את הישויות, ואילו codekeeper_get_note ו- codekeeper_get_note_version מחזירים את הגוף בצורה המאוחסנת — למה, ב-קריאת פתק בודד — codekeeper_get_note.

כלי אדמין (דפדפן הריפו — מוסתרים וחסומים לכל משתמש אחר):

כלי

תיאור

codekeeper_list_repos

הריפואים המשוקפים (מטא-דאטה)

codekeeper_list_repo_tree

נתיבי קבצים בריפו (עימוד, סינון תיקייה/ref; בלי תוכן). include_stats=true מוסיף entries עם גודל וספירת שורות לכל נתיב — ראו גודל וספירת שורות ברשימת הריפו

codekeeper_get_repo_file

תוכן קובץ בודד (עד 500KB לקובץ מלא, עד 10MB עם lines; קובץ בינארי ← מטא-דאטה בלבד). lines=[start, end] כמו ב-codekeeper_get_file, באותה סמנטיקה בדיוק — ראו קריאת טווח שורות

codekeeper_search_repo

חיפוש טקסט בריפו (קטעים קצרים עם path+line, עם תקרות). context_lines=N (0‑10) מחזיר גם context_before ו-context_after לכל פגיעה, במקום למשוך את הקובץ כולו רק כדי לראות את הסביבה. total הוא כמה מופעים יש בריפו ולא כמה הוחזרו, וקוד חיצוני וקבצים מקומפלים מוחרגים מהחיפוש ומהספירה כברירת מחדל — ראו כמה מופעים יש באמת (total מול total_at_least). ההתאמה אינה רגישה לרישיות כברירת מחדל; case_sensitive=true מבקש התאמה מדויקת

codekeeper_read_batch

כמה סעיפי תיעוד וקבצים מהמראות בקריאת כלי אחת, במקום קריאה לכל אחד — למשל כל הדפוסים שסבב ריוויו חייב לקרוא. כל פריט עובר דרך ה-handler של הכלי שהוא משקף (codekeeper_docs_get_section או codekeeper_get_repo_file), ולכן התשובה שלו זהה בית-בית לזו של הכלי הבודד, כולל סירובים. כל פריט נספר כקריאה אחת מול מכסת הקצב — ראו קריאה קבוצתית — codekeeper_read_batch

codekeeper_list_repo_notes

פתקים דביקים על קובץ בריפו משוקף (repo_name + repo_path) — אותם פתקים שמוצגים בדפדפן הריפו בוובאפ. מחזיר orphaned: true כשהנתיב כבר אינו בעץ המשוקף, והפתקים עצמם חוזרים בכל מקרה

codekeeper_list_repo_note_paths

מפת גילוי: אילו קבצים בריפו נושאים פתקים, וכמה על כל אחד. codekeeper_list_repo_notes דורש את הנתיב המדויק מראש — כלומר בלי המפה צריך כבר לדעת איפה הפתק כדי למצוא אותו

codekeeper_create_repo_note

כתיבה: פתק חדש על קובץ בריפו משוקף. mode כמו בלוח; title אופציונלי, וייחודי על אותו קובץ. דורש write

הערה

דפדפן הריפו הוא קריאה בלבד לצמיתות (החלטת עיצוב) — אין, ולא תתווסף, עריכה/commit/push לריפו GitHub. ה-mirrors בדיסק הם רפליקה חד-כיוונית מ-GitHub (מקור האמת), וה-MCP לעולם לא כותב אליהם.

codekeeper_create_repo_note אינו יוצא מן הכלל הזה. הפתק נכתב לאוסף sticky_notes שב-CodeKeeper, והוא הערה על הקובץ ולא שינוי בו — לא ב-mirror ולא בריפו ב-GitHub. כתוב כאן כדי שאיש לא יסיק אחרת משם הכלי.

הקבועים, התקרות ואוצר המילים של התשובה

המספרים כאן חיים בקוד ונאכפים בו. ההפניה היא לשם המודול ולשם הקבוע ולא למספר שורה, כדי שהטבלה לא תצביע למקום אחר אחרי הריפקטור הבא.

הקבוע

הערך

מה הוא מגביל

MAX_FILE_SIZE_FOR_DISPLAY

512,000

קריאה מלאה של קובץ. יושב ב-services/git_mirror_service.py ומשותף עם הוובאפ

RANGE_READ_MAX_BYTES

10,485,760

קריאת טווח ואאוטליין, ב-mcp_server/repo_backend.py

OUTPUT_BYTE_BUDGET

256,000

תקציב הבתים של תשובה אחת

DEFAULT_MAX_REQUEST_BYTES

1,048,576

גוף בקשה אחת במתודות שנושאות גוף, ב-mcp_server/limits.py. נגזר מ-MAX_CODE_SIZE (request_bytes_for) ולא מוקלד לצידו; ‏``MCP_MAX_REQUEST_BYTES`` מעלה אותו. ראו ”גבולות הבקשה“

DEFAULT_RATE_LIMIT_PER_MINUTE

45

קריאות כלים לזהות אחת בדקה, ב-mcp_server/limits.py; ‏``MCP_RATE_LIMIT_PER_MINUTE`` מכוון אותו. נבחר כך שכפול WORST_CASE_CPU_SECONDS לא יעבור את מכסת המעבד — ראו ”גבולות הבקשה“

MAX_LINES

8,000

שורות בקובץ Markdown, ב-services/md_parser.py. נבדק לפני הפרסור; מעליו too_many_lines — ראו איזה קובץ docs_get_section קורא, ובאיזה ריפו

MAX_TOKENS

45,000

טוקני בלוק בפרסור Markdown אחד, ב-services/md_parser.py. נבדק ברגע שנוצר כל טוקן; מעליו too_many_tokens — ראו איזה קובץ docs_get_section קורא, ובאיזה ריפו

WORST_CASE_CPU_SECONDS

0.66

לא תקרה אלא מדידה: זמן המעבד של הפרסור הגרוע שנמצא תחת MAX_LINES ו- MAX_TOKENS, ב-services/md_parser.py. ממנו נגזרים DEFAULT_RATE_LIMIT_PER_MINUTE ו-DEADLINE_SECONDS, וטסט בודק את שני החשבונות

TREE_PER_PAGE_DEFAULT · TREE_PER_PAGE_MAX

200 · 1000

עמוד בעץ הקבצים

OUTLINE_PER_PAGE_DEFAULT · OUTLINE_PER_PAGE_MAX

100 · 500

סימבולים בעמוד באאוטליין. קבועים משלו ולא מיחזור של TREE_*, כי רשומת סימבול שוקלת יותר מנתיב

REPOS_LIMIT_DEFAULT · REPOS_LIMIT_MAX

50 · 200

ריפואים ב-codekeeper_list_repos

SEARCH_RESULTS_DEFAULT · SEARCH_RESULTS_MAX

50 · 100

פגיעות ב-codekeeper_search_repo

CONTEXT_LINES_MAX

10

שורות ההקשר סביב פגיעה בחיפוש

SEARCH_COUNT_CEILING

100,000

התקרה שעד אליה codekeeper_search_repo סופר מופעים. יושב ב-services/git_mirror_service.py, כי הספירה היא של המנוע. מעליה אין total אלא total_at_least — ראו כמה מופעים יש באמת (total מול total_at_least)

GREP_COUNT_MAX_FILES

200,000

קבצים תואמים שמעבר הספירה קורא, ב-services/git_mirror_service.py. הגנה מפני פלט פתולוגי; אינו נגזר מ-max_results

QUERY_RESULTS_DEFAULT · QUERY_RESULTS_MAX · QUERY_CONTEXT_LINES_MAX · QUERY_OUTPUT_BYTE_BUDGET

50 · 100 · 10 · 256,000

חיפוש בתוך קובץ שמור (codekeeper_get_file עם query). יושבים ב-mcp_server/handlers.py, ו**אותם ערכים בדיוק** כמו ארבעת הקבועים המקבילים שמעליהם — repo_handlers מייבא מ-handlers, ולכן ייבוא הפוך היה מעגלי והם משוכפלים. tests/test_mcp_file_query.py משווה את שני העותקים וגם מעגן כל ערך למספר שכתוב בו

QUERY_SNIPPET_MAX_BYTES

500

טקסט של רשומה אחת בתשובת query, בבתים. אותו מספר שחותך codekeeper_search_repo, ביחידה שנאכפת — ראו חיפוש בתוך קובץ שמור (query)

MAX_SYMBOLS

50,000

סימבולים לקובץ באאוטליין. יושב ב-mcp_server/outline_scanners/_ceiling.py

MAX_BATCH_ITEMS

20

פריטים בקריאה אחת של codekeeper_read_batch, ב-mcp_server/read_batch.py. נדחה ולא נצמד (too_many_items), ויורד עם מכסת הקצב כשהיא נמוכה ממנו (item_cap). ראו קריאה קבוצתית — codekeeper_read_batch

DEADLINE_SECONDS

10

שניות קיר לבאץ«, מהכניסה ל-call_tool, ב-mcp_server/read_batch.py. נבדק בין פריטים בלבד, ולמה זה מספיק — ב-קריאה קבוצתית — codekeeper_read_batch

MAX_RESULT_CHARS

256,000

_meta["anthropic/maxResultSizeChars"] של codekeeper_read_batch, ב-mcp_server/read_batch.py. הוא OUTPUT_BYTE_BUDGET עצמו: בתים כתקרת תווים, ולמה זה בטוח — ב-קריאה קבוצתית — codekeeper_read_batch

כל קבוע שהטבלה אינה נוקבת במודול שלו יושב ב-mcp_server/repo_handlers.py, ליד ה-handler שאוכף אותו.

אזהרה

ערך מעל התקרה נצמד בשקט, ואינו נדחה. per_page=1000 באאוטליין מקבל 500, ואין בתשובה שדה שמצהיר שזה קרה — מה שכן יש הוא per_page שחוזר בתשובה, והוא הערך שנאכף, ולכן זה מה שכדאי להשוות אליו ולא למה שנשלח. per_page=0 נצמד ל-1 ולא לברירת המחדל.

ומה שאינו מספר שלם אינו מגיע להצמדה בכלל — הוא נדחה בסכימה. נמדד מול הסכימה של הכלי: ערך עם שבר (2.5), null, ומחרוזת שאינה מספר נדחים לפני שהכלי רץ; מחרוזת מספרית ("250") ומספר עשרוני שלם (2.0) עוברים ומומרים. ובוליאני עובר ונעשה 1, בלי שגיאה: per_page=true מחזיר עמוד של רשומה אחת. זו אותה המרה ש-lines חוסם ב-strict — ראו את ההערה שם — וכאן היא אינה חסומה.

ההצמדה היא מדיניות מוצהרת (_clamp ב-mcp_server/handlers.py) ולא מקריות: דחייה על כל ערך מחוץ לטווח הייתה מכשילה קריאה שלמה בגלל פרמטר עימוד. התקרה על הגודל דווקא כן דוחה, וההבדל מכוון — עמוד שאינו נכנס בתקציב הבתים חוזר כ-page_too_large ולא נחתך בשקט. וכך גם מספר הפריטים בבאץ«: codekeeper_read_batch עם יותר מ-MAX_BATCH_ITEMS פריטים נדחה ב-too_many_items ולא נחתך, כי באץ« שנחתך בשקט היה מחזיר פחות ממה שביקשו, בלי לומר אילו פריטים חסרים.

אוצר המילים של התשובה, ושתי המשפחות אינן מתערבבות. status מתאר הצלחה, error מתאר סירוב, וב**תשובת כלי** error מגיע תמיד עם "ok": false. תשובת HTTP של השרת עצמו אינה תשובת כלי ואינה נושאת את השדה — 401 על טוקן פסול, למשל, הוא {"error": "invalid_token"} לבדו, עם הכותרת WWW-Authenticate. ל-status יש בתשובת כלי רשימה סגורה: ok, outline, query, binary, too_large ו-no_outline. ל-error אין רשימה כזאת כאן בכוונה — הערכים נגזרים מהכלי שסירב (path_denied, outline_and_lines, query_and_lines, page_too_large, range_out_of_bounds ועוד רבים), ורשימה חלקית שנראית סגורה גרועה מאין רשימה. ערך status חדש שיתווסף לקוד מתווסף גם כאן.

``truncation_reason`` אינו אף אחד מהשניים. הוא אינו מתאר הצלחה ואינו מתאר סירוב, אלא למה תשובה מוצלחת אינה שלמה — ולכן הוא מופיע רק כאשר truncated הוא true, ולעולם לא כ-null בתשובה תקינה. הערכים: max_results (התוצאות נחתכו בתקרה שביקשו), byte_budget (העמוד לא נכנס בתקציב הבתים), count_ceiling ו-count_output_limit (הספירה נעצרה בתקרה), count_failed (הספירה לא הסתיימה — קוד יציאה, או error: ב-stderr גם כשהקוד תקין), matches_per_file (התקרה של ההתאמות לכל קובץ), policy_filtered (שורה הוסרה במדיניות הסודות), search_failed (המנוע נפל אחרי שכבר אסף תוצאות — הן מוגשות, ואינן שלמות), timeout, output_limit ו-process_killed. כשהספירה היא זו שנקטעה, הסיבה שלה גוברת — כי היעדר total הוא מה שדורש הסבר, ואילו חיתוך התוצאות כבר משתמע מ-count שקטן מ-total_at_least.

``unread_reason`` אינו ``truncation_reason``, ובכוונה. תשובה של codekeeper_read_batch אינה חתוכה: כל פריט בה שלם, ומה שחסר בה הוא פריטים שלמים שלא נכנסו או שלא הגיעו אליהם בזמן. לכן אין בה truncated, והסיבה נקראת בשם אחר — unread_reason, שמופיע רק לצד unread (האינדקסים שלא נקראו) ולעולם לא בלעדיו. שני הערכים שלו לקוחים מאותו אוצר מילים: byte_budget ו-timeout. ראו קריאה קבוצתית — codekeeper_read_batch.

הערה

codekeeper_get_file הוא היחיד שנושא status רק לפעמים, וזה מכוון: הוא מעולם לא נשא את השדה, ולכן קריאה רגילה וקריאת טווח מחזירות היום בדיוק את מה שהחזירו קודם. status: "query" מופיע אך ורק כשהועבר query, ומסמן שהתשובה נושאת מופעים ולא תוכן.

אימות והרשאות

שני מסלולים, מאוחדים באותו שרת:

  1. OAuth 2.1 — עבור Claude.ai (Custom Connector). זרימה מלאה: רישום לקוח דינמי (DCR) + PKCE + מסך אישור. הזהות נקבעת דרך התחברות הטלגרם בוובאפ.

  2. טוקן אישי (PAT) — עבור Claude Code / Desktop. מונפק מהבוט בפקודת /connect_claude (או /connect_claude write לטוקן עם הרשאת כתיבה), נשמר כ-hash בלבד וניתן לביטול.

שכבות ההרשאה:

  • read — ברירת המחדל לכל חיבור.

  • write — נדרש לכל כלי שמשנה נתונים; ניתן רק באישור מפורש (מסך ההרשאה ב-Claude.ai או טוקן write מהבוט).

    הערה

    מה נחשב ”כלי כתיבה“ נקבע במטא-דאטה של הכלי, לא ברשימת שמות. ‏``_declares_write`` ב-mcp_server/server.py קורא את readOnlyHint שהכלי כבר מצהיר עליו, ו-require_write נקרא בשורה הראשונה של גוף הכלי. רשימת שמות הייתה מקום שני לסנכרן, והכלי שנוסף בשבוע הבא הוא בדיוק זה שהיה נשמט ממנה — בלי שגיאה ובלי בדיקה שנופלת. הטבלה שלמעלה מסמנת את כלי הכתיבה ב-כתיבה:, והיא תיעוד של המצב ולא המקור שלו.

  • אדמין — כלי הריפו זמינים רק למשתמשים שב-ADMIN_USER_IDS; לכל אחד אחר הם גם לא מופיעים ברשימת הכלים וגם נחסמים בקריאה ישירה (fail-closed). זה חל גם על שלושת כלי פתקי הריפו, אף שהם אינם נוגעים ב-mirror. codekeeper_create_repo_note הוא הכלי היחיד שעובר שני שערים, ובסדר הזה: אדמין ואז כתיבה. בסדר ההפוך משתמש רגיל היה מקבל ”צריך הרשאת כתיבה“ — רמז שטוקן אחר יפתח לו את הכלי, וזה אינו נכון.

  • codekeeper_search_notes אינו כלי אדמין, אף שהוא מוצא גם פתקי ריפו: הוא מסונן ל-user_id של הקורא ולכן יכול להחזיר רק פתקים שלו, ומשתמש רגיל אינו יכול ליצור פתק ריפו מלכתחילה.

  • codekeeper_get_note גם הוא אינו כלי אדמין — אבל פתק ריפו חסום בו לאדמין בדיוק כמו ב-codekeeper_list_repo_notes, רק שהשער נסגר בשקט: מי שאינו אדמין מקבל not_found, אותה תשובה כמו לפתק של מישהו אחר ולמזהה שאינו קיים, כך שהסירוב אינו מגלה שהמזהה קיים (require_admin היה זורק admin_only ומגלה). הבדיקה נעשית לפי סוג הפתק, לפני שתוכן כלשהו חוזר. ראו קריאת פתק בודד — codekeeper_get_note.

גבולות הבקשה — גודל הגוף והקצב

עד #3431 המידלוור היחיד בשרת היה האימות: כל משתמש מאומת יכול היה לשלוח בקשות בכל גודל ובכל תדירות. תקרת האורך ל-path של codekeeper_docs_get_section (#3428) חסמה הגברה בבקשה בודדת, ולא נגעה לא במספר הבקשות ולא בשאר הכלים. היום יש שני גבולות, כל אחד בשכבה שמתאימה לו, ושניהם חיים ב-mcp_server/limits.py.

גודל גוף הבקשה — במידלוור ASGI, לפני שהטרנספורט מפענח JSON. הטרנספורט של ה-SDK קורא את הגוף כולו ומפענח אותו לפני שאף כלי רואה ארגומנט, ולכן התקרה הזאת היא הגבול היחיד על כל ארגומנט של כל כלי, קיים ועתידי. התקרה נגזרת ולא מוקלדת: הארגומנט הלגיטימי הגדול ביותר הוא code של codekeeper_save_file, שחסום ב-MAX_CODE_SIZE (תווים), ולקוח שמקודד JSON עם ensure_ascii הופך כל תו עברי לשישה בתים; request_bytes_for ב- mcp_server/limits.py מחשב MAX_CODE_SIZE × 6 ועוד מעטפת של 64KiB (שם הכלי, שם הקובץ, שפה, ותיאור בתקרתו) ומעגל כלפי מעלה ל-MiB שלם — על ברירת המחדל של התצורה זה 1MiB. השירות גוזר את הערך בעלייה מהתצורה שהוא באמת רץ איתה (max_code_size()), כך שהעלאת MAX_CODE_SIZE לעולם אינה משאירה את התקרה מאחור, וטסט בונה את בקשת השמירה הגדולה ביותר (קובץ עברי בגודל המקסימלי, בקידוד היקר) ומודד שהיא נכנסת.

מה נקרא ומה לא, ולמה זה חשוב. התקרה מסתכלת רק על מתודות שנושאות גוף (POST, ‏``PUT``, ‏``PATCH``); GET ודומיה עוברות כמות שהן, ולכן Content-Length מזויף על GET /healthz אינו 413 (עד סקירת שבעת ה-PRים הוא כן היה, מול uvicorn אמיתי). Content-Length תקין — ספרות בלבד, כמו ש-h11 מקבל — שמצהיר על יותר מהתקרה נדחה לפני שנקרא בית אחד; תקין ומתחת לתקרה, בלי Transfer-Encoding לצידו, עובר בלי שהמידלוור קורא בית, כי השרת (uvicorn/h11) תוחם את הגוף לאורך המוצהר ואי אפשר להעביר דרכו יותר — נמדד. מה שנשאר לספירה הוא גוף בלי אורך מוצהר (chunked, או Content-Length לצד Transfer-Encoding, ששם h11 תוחם לפי chunked): הוא נקרא עד התקרה, וכל הלולאה תחת דדליין של 30 שניות (DEFAULT_BODY_READ_SECONDS), כי ל-uvicorn אין timeout לגוף בקשה וההמתנה לנתח הבא הייתה אחרת בלתי חסומה. הסירוב נוקב בסיבתו, באותה צורה של ה-401 של האימות, ונושא Connection: close — הוא הגיע לפני שהגוף נגמר, ואין טעם להמשיך לקרוא אותו:

{"error": "body_too_large", "max_bytes": 1048576, "content_length": 20971520}
{"error": "body_read_timeout", "read_timeout_seconds": 30.0, "received_bytes": 4096}

במצב PAT האימות נשאר מחוץ לתקרה (בקשה בלי טוקן היא 401 גם כשהיא ענקית). במצב OAuth התקרה היא השכבה החיצונית ברמת האפליקציה, והאימות של ה-SDK יושב בתוך ה-mount של /mcp: אורך מוצהר מעל התקרה נדחה לפני שנבדק אישור כלשהו, ואורך מוצהר תקין עובר בלי קריאה, כך שה-401 של ה-SDK עדיין מגיע בלי שנקרא בית מהגוף. זו הייתה רגרסיה של #3431 (SEC-001 בסקירת שבעת ה-PRים): הגרסה הראשונה קראה כל גוף אנונימי עד התקרה לפני ה-401 — נמדדו 5 קריאות receive() לפני התשובה על גוף של חמישה נתחים, גם עם אורך מוצהר — ובלי דדליין. היום: 0 קריאות לאורך מוצהר, וגוף chunked אנונימי, המקרה שנשאר, חסום בתקרה ובדדליין.

קצב — לפי זהות, בנקודת השיגור של הכלים. הזהות שאפשר לספור לפיה קיימת במידלוור רק במצב PAT; במצב OAuth (הייצור) ה-SDK מאמת בתוך /mcp, והמקום היחיד שרואה את הזהות בשני המצבים הוא קריאת הכלי עצמה. לכן המגביל יושב ב-call_tool של AdminAwareFastMCP — המתודה שה-SDK רושם כמטפל של tools/call, כלומר נקודה אחת שכל קריאת כלי עוברת בה — ומכריע לפני שגוף סינכרוני נמסר לחוט מהמאגר ולפני שגוף אסינכרוני רץ על הלולאה: קריאה שנדחתה אינה עולה עובד, ומה שאינו קריאת כלי (initialize, ‏``tools/list``) אינו נספר. הוא בנוי מעל rate_limiter.RateLimiter — אותו חלון מתגלגל של דקה שהבוט משתמש בו, בזיכרון ובלי Redis, וזה מספיק כי השירות רץ במופע אחד — הגדרה של השירות ב-Render (numInstances: 1), מחוץ לריפו; מי שמוסיף מופע מאבד את הזיכרון המשותף, וזה המקום להתחיל ממנו. ברירת המחדל היא DEFAULT_RATE_LIMIT_PER_MINUTE קריאות בדקה לזהות, ומאיפה המספר: הטענה שהוא שומר עליה היא שזהות אחת אינה עוברת דקת מעבד אחת בדקה, גם כשכל הקריאות שלה הן הקלט היקר ביותר שנמצא. המכסה היא 0.5 מעבד, כלומר 30 שניות-מעבד בדקה, והקלט היקר ביותר הוא פרסור Markdown תחת התקרות של md_parser — WORST_CASE_CPU_SECONDS ב-services/md_parser.py, שם גם הקלט, המדידה והתאריך. החשבון: 45 × 0.66 = 29.7 שניות-מעבד בדקה, מתוך 30. ‏``tests/test_md_parse_worst_case_claims.py`` גוזר אותו מהקבועים ונופל כשהוא נשבר, והשורה הזאת נבדקת שם מול אותם קבועים. עד #3391 המגבלה הייתה 60, והיא נגזרה מהמסמך הצפוף האמיתי (0.47 שניות מעבד; שישים קריאות שלו הן 28 שניות) — כי הקלט העוין של אז לא היה חסום בכלל, ושישים קריאות שלו היו פי כמה מהמכסה. עם התקרות הוא חסום, ו-60 היה משאיר אותו מעל המכסה: טענה שצריך להסביר במקום לבדוק. עמוד RST של 500KB עולה 0.24 שניות עם תקרת הסקשנים (נמדד ב-#3431), וקריאה רגילה (10–50ms) הופכת את המגבלה לאחוזים בודדים מהמכסה. הסירוב הוא תשובת כלי רגילה, עם הזמן שנותר עד שהחלון משתחרר:

{"ok": false, "error": "rate_limited", "limit_per_minute": 45, "retry_after_seconds": 17}

באץ« נשקל כמספר הפריטים שלו. codekeeper_read_batch עם N פריטים עולה N קריאות מהמכסה, כי כל פריט הוא קריאה ופרסור בדיוק כמו קריאה בודדת — אחרת באץ« היה דרך לעקוף את המכסה פי MAX_BATCH_ITEMS. שלושה דברים קורים ב-call_tool לפני שדבר נקרא. רגע הכניסה נרשם, והדדליין של הבאץ« נמדד ממנו. רשימה ריקה ורשימה מעל התקרה נדחות לפני השקילה (missing_items, too_many_items), כך שבקשה שלעולם לא תעבור שומעת את הסיבה שלה, ולא rate_limited עם זמן המתנה שלא יעזור. זה נעשה רק לאדמין: מי שאינו אדמין נשקל כקריאה אחת ומסורב בגוף הכלי ב-admin_only, בלי ללמוד את התקרה של כלי שהוא אינו רואה. והשקילה היא הכל או כלום: באץ« שאינו נכנס כולו במכסה נדחה כולו, לפני שפריט אחד נקרא, ו-retry_after_seconds הוא הזמן עד שכל המשקל נכנס. check_rate_limit ו-seconds_until_allowed של RateLimiter מקבלים weight, וברירת המחדל 1 משאירה את הבוט בדיוק כמו שהיה. התקרה של הכלי, MAX_BATCH_ITEMS, קטנה מ-DEFAULT_RATE_LIMIT_PER_MINUTE כדי שבאץ« מלא יוכל לעבור בכלל, וכשהמכסה בפריסה נמוכה ממנה — התקרה יורדת איתה (item_cap). פריטים שלא נקראו אינם מוחזרים למכסה; הנימוק ב-קריאה קבוצתית — codekeeper_read_batch.

הפטור לנתיב הדופק — שני חצאים, ורק אחד מהם מבני. מהמגביל /healthz פטור במבנה: הוא אינו קריאת כלי ולכן לעולם אינו מגיע למגביל. זו המלכודת שמתוארת ב-blanket-policy-silent-block §7 — מגבלה גורפת שמחזירה 429 למוניטור החיצוני ומכריזה על שירות בריא כמנותק — והיא נסגרת כאן על ידי המבנה. מתקרת הגוף הוא פטור בכלל, לא במבנה: GET אינה מתודה שנושאת גוף, ולכן התקרה אינה מסתכלת עליה — עד סקירת שבעת ה-PRים היא כן הסתכלה, ו-GET /healthz עם Content-Length מזויף מעל התקרה ענה 413. שני החצאים מקובעים בטסטים על האפליקציה האמיתית, בשני מצבי האימות: מאה דגימות של /healthz תחת מגבלה של קריאה אחת בדקה מחזירות מאה 200, ו-GET /healthz עם אורך מזויף מחזיר 200.

מה נרשם בלוג. בעלייה — שורה אחת עם שני הגבולות שנקבעו; סירוב על גודל או על הדדליין — WARNING עם הסיבה, המספרים והנתיב (לא הגוף ולא הטוקן); סירוב על קצב — WARNING אחד לזהות לחלון, כדי שסוכן שממשיך לדפוק לא יהפוך את הלוג לתור. וסירובי המכסה אינם נספרים ב-PostHog: המדידה עוטפת את _tool_manager.call_tool של ה-SDK, וההכרעה על המכסה קורית לפניו, ולכן קריאה שסורבה אינה נראית שם כלל; וגם סירוב שכן עובר במדידה (תשובת {"ok": false, ...} מגוף כלי) נרשם כהצלחה, כי is_error מסומן רק על חריגה. האות היחיד על סירובי מכסה הוא ה-WARNING ההוא, שאי אפשר לסכם. ההחלטה מפורטת באישו #3442, ושם גם מה שנשאר לעצב.

כיוון. ‏``MCP_MAX_REQUEST_BYTES`` ו-MCP_RATE_LIMIT_PER_MINUTE (‏``0`` מכבה במפורש, עם WARNING). שניהם נקראים ב-create_app — בכניסה לשירות, לא בזמן ייבוא — וערך שאינו מספר מחזיר את ברירת המחדל עם WARNING; בשום מסלול טעות תצורה אינה הופכת לגבול רחב יותר. ``MCP_MAX_REQUEST_BYTES`` אינו kill switch, וזו החלטה סגורה (YAGNI-002 בסקירת שבעת ה-PRים): 0 אינו מכבה אותו — הרצפה של 65,536 בתים חלה — והוא קיים כדי להעלות את התקרה בלי דיפלוי כשבקשה לגיטימית נדחית ב-body_too_large, למשל אחרי שכלי יקבל ארגומנט גדול מ-MAX_CODE_SIZE. הדדליין על גוף בלי אורך מוצהר (30 שניות) אינו ניתן לכיוון מהסביבה: אין לו קורא שמצדיק זאת.

עדכון תיאור בלי גרסה חדשה

codekeeper_update_file_description מחליף את ה-description של קובץ קיים, ולא נוגע בשום דבר אחר. הוא נועד למקרה שבו התיאור השמור התיישן ביחס לתוכן — הקובץ עצמו עודכן, והשורה שמתארת אותו בחיפוש וברשימות נשארה מלפני כמה עריכות.

עד שהוא נוסף לא הייתה לכך דרך דרך ה-MCP: codekeeper_save_file מקבל description אבל מסרב לקובץ שכבר קיים, ו-codekeeper_edit_file ו-codekeeper_append_file מעתיקים את התיאור הקיים לגרסה החדשה ואין להם פרמטר לשנות אותו. הדרך היחידה לרענן תיאור הייתה מהדפדפן.

זהו אותו מסלול בדיוק של ”עדכון תיאור מהיר“ שכבר קיים בעמוד הקובץ בוובאפ: שני הערוצים קוראים ל-update_file_metadata_in ב-database/repository.py, ואין שני מימושים של אותה כתיבה.

חשוב

לא נוצרת גרסה, ולכן התיאור הקודם אינו נשמר בשום מקום.

השינוי הוא כתיבה אחת על מסמך הגרסה האחרונה. שלוש נגזרות שחשוב להכיר:

  • codekeeper_list_versions לא יציג את השינוי, ואין ממה לשחזר את הערך הקודם. התשובה של הכלי מחזירה אותו (previous_description) — וזה המקום היחיד שבו הוא עוד קיים.

  • רק הגרסה האחרונה מתעדכנת. גרסאות קודמות נשארות עם התיאור הישן, וקריאה מפורשת של גרסה ישנה (codekeeper_get_file עם version) תחזיר אותו. זה עקבי עם מה שמוצג: גם החיפוש וגם רשימות הקבצים מקבצות לגרסה האחרונה לכל שם קובץ וקוראות את description ממנה.

  • התוכן ומספר הגרסה אינם זזים. מספר הגרסה מוחזר בתשובה במפורש כדי לומר זאת.

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

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

הקריאה מסמנת את התיאור כנבדק. היא מאפסת את description_age_versions (ראו גיל התיאור — description_age_versions) גם כששלחתם בדיוק את הטקסט שכבר כתוב שם — ואז התשובה נושאת unchanged: true, כדי שיהיה גלוי ששום דבר לא השתנה מלבד החותמת. זה הפוך מהכלל של עריכה, וזו הכרעה: עריכה שמעתיקה תיאור אינה אומרת דבר על התוכן, וקריאה לכלי הזה אומרת שמישהו השווה ביניהם. ניקוי התיאור מסיר את החותמת במקום לאפס אותה — לקובץ בלי תיאור אין גיל.

גיל התיאור — description_age_versions

תיאור מתיישן בשקט. codekeeper_edit_file ו-codekeeper_append_file מעבירים את התיאור הקיים לגרסה החדשה, וזו ברירת מחדל נכונה — עריכת שורת קוד אינה מבטלת את מה שכתוב על הקובץ. מה שהיה חסר הוא הסימן: תיאור שנכתב בגרסה 1 נראה בגרסה 11 בדיוק כמו תיאור שנכתב אתמול, ואי אפשר היה לדעת שכדאי לבדוק אותו בלי לקרוא את כל הקובץ.

השדה עונה על זה: כמה גרסאות הקובץ זז מאז שהתיאור נקבע. הוא מוחזר על ידי codekeeper_list_files, codekeeper_search_code ו-codekeeper_get_file, ולצידו כבר יושב version — 3 בלי לדעת שהקובץ בגרסה 4 אינו אומר דבר.

חשוב

זה רמז לבדוק, לא קביעה שהתיאור שגוי. עשר עריכות קטנות אינן הופכות תיאור לשגוי, ועריכה אחת גדולה כן יכולה. מה שהמספר אומר הוא ”התיאור הזה לא נגע בתוכן כבר כמה זמן“, ולא ”התיאור הזה לא נכון“.

ו-null אינו אפס. אפס הוא טענה — התיאור נקבע לגרסה הזו — ו-null אומר שאיננו יודעים. שדה שנעדר לגמרי אומר משהו שלישי: אין כאן מה לבדוק.

מה חוזר

מתי

מספר

הפרש בין מספר הגרסה לגרסה שבה התיאור נקבע.

null

לקובץ יש תיאור, ואין חותמת שאפשר למדוד ממנה — נשמר לפני שהשדה קיים, או שהמיגרציה לא הצליחה לשחזר את שרשרת הגרסאות שלו.

השדה נעדר

לקובץ אין תיאור, או שהוא כלל אינו ממוספר בגרסאות — קובץ גדול (large_files) נשמר בדריסה ואין לו version. במקרים האלה null היה שולח לבדוק משהו שאין בו מה לבדוק.

מתי החותמת זזה

החותמת עצמה (description_set_at_version) מאוחסנת על מסמך הגרסה, ו**הגיל מחושב בזמן קריאה** ואינו נשמר: ערך שמור היה חייב להתעדכן בכל שמירה, והשמירה שתשכח אותו הייתה מייצרת בדיוק את השקר שהשדה בא לחשוף.

  • גרסה חדשה עם תיאור ששונה מהקודם ← החותמת היא מספר הגרסה החדשה.

  • גרסה חדשה עם אותו תיאור ← החותמת עוברת כמות שהיא מהגרסה הקודמת. זה מה שגורם לגיל לספור עריכות במקום להתאפס בכל אחת מהן.

  • עדכון תיאור בלי גרסה (codekeeper_update_file_description והעדכון המהיר בעמוד הקובץ) ← החותמת היא הגרסה הנוכחית, גם כשהטקסט זהה.

  • גרסה קודמת בלי חותמת ← אין חותמת גם בחדשה. אי-ידיעה נשארת אי-ידיעה; ”השלמה“ בגרסה החדשה הייתה הופכת אותה לטענה הגרועה ביותר האפשרית, שהתיאור נכתב עכשיו.

הכלל יושב במקום אחד, file_description.py שבשורש, וכל מסלול שכותב גרסה מריץ אותו — מסלול השמירה של הבוט ושל ה-MCP (save_code_snippet) והראוטים בוובאפ שכותבים ישירות לאוסף. החותמת נגזרת מהשוואה בין התיאור שנכתב לזה של הגרסה הקודמת ואינה מועתקת ביד, כי העתקה ידנית הייתה נשמטת במסלול הראשון שמישהו יוסיף — והשדה היה מדווח ”עדכני“ בדיוק על המקרה שהוא נועד לחשוף.

הערה

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

קבצים שנשמרו לפני שהשדה קיים

הם מחזירים null עד שמריצים מיגרציה חד-פעמית שמשחזרת את החותמת מההיסטוריה:

python scripts/migrate_description_set_at_version.py --dry-run
python scripts/migrate_description_set_at_version.py

היא הולכת אחורה בשרשרת הגרסאות כל עוד התיאור זהה, וקובעת את החותמת לגרסה המוקדמת ביותר ברצף. כששרשרת הגרסאות קטועה — חסר מספר גרסה באמצע הדרך אחורה, מה שקורה כשגרסאות עברו לסל — היא משאירה את הקובץ בלי חותמת, כי התיאור בגרסה החסרה הוא בדיוק מה שהיה מכריע. הדוח מפריד בין ”קיבלו חותמת“, ”הושלמו“, ”כבר היו שלמים“, ”חותמת שונה מהשרשרת“, ”נשארו null“ ו“בלי תיאור“ — כל אחד מהם מצב אחר, ואיחוד שלהם היה הופך את הדוח לבלתי קריא.

היא אינה דורסת חותמת קיימת לעולם. אבל ”לא לדרוס“ אינו ”לא לגעת“, והשניים נבדלים בדיוק במקרה שבו הרצה חוזרת נחוצה: הכתיבה אינה אטומית בין מסמכים, ולכן הרצה שנקטעה משאירה חלק מהשרשרת כתוב וחלק לא.

  • החותמת הקיימת שווה למה שהשרשרת אומרת ← המסמכים החסרים מושלמים. בטוח בהגדרה, כי הערך שנכתב זהה לזה שכבר שם.

  • החותמת הקיימת שונה ← לא נוגעים. codekeeper_update_file_description מזיז את החותמת בלי ליצור גרסה, ומאותו רגע שרשרת הגרסאות כבר אינה מתארת אותה; חישוב מחדש היה מחזיר את הגיל אחורה ומוחק עדכון אמיתי.

קריאת פתק בודד — codekeeper_get_note

עד שהכלי הזה נוסף לא הייתה דרך לקרוא פתק אחד לפי מזהה. codekeeper_search_notes מחזיר מזהה בלי תוכן, codekeeper_get_note_version מחזיר תוכן רק לגרסאות קודמות, וכלי הרשימה מחזירים את המשטח כולו — לוח של כמה עשרות פתקים חוזר בעשרות אלפי תווים כדי להגיע לפתק אחד, וזה יותר ממה שלקוח מציג. סוכן בלי shell לא יכול היה לקרוא את הפתק בכלל, ו- codekeeper_note_str_replace, שדורש את הגוף הנוכחי המדויק, חייב משיכה של הלוח כולו לפני כל עריכה. התקציב של השרת לא הגן על המקרה הזה, ואינו נאכף על רשימות פתקים כלל: OUTPUT_BYTE_BUDGET נמדד ונאכף בכלי הריפו (עץ, אאוטליין וחיפוש), ו-query נאכף בקבוע משלו, QUERY_OUTPUT_BYTE_BUDGET; על רשימות פתקים אף אחד מהם אינו נאכף — לוח שלם עבר מתחתיו, ומה שחסם היה הלקוח. אכיפה שם היא שינוי נפרד, רשום ב-#3457 יחד עם שאר הפריטים שנדחו מה-PR. גוף פתק אחד חסום ב-MAX_NOTE_CHARS תווים, ולכן תשובה של codekeeper_get_note חסומה מראש.

מה חוזר. note — הפתק באותה צורה שכלי הרשימה מחזירים (תוכן, כותרת, color ו-color_id, שורה, מועדים); version — מספר הגוף הנוכחי (null לגוף ריק: ההיסטוריה אינה מצלמת גוף ריק, ולכן אין מספר שיחזיר אותו, ולא ממציאים אחד); ואיפה הפתק יושב, במוסכמה של פגיעת חיפוש: target ואיתו בדיוק הארגומנטים שכלי הרשימה המתאים דורש — file_name (ו-file_id), board_id, או repo_name + repo_path. פתק קובץ ישן שנושא file_id בלבד מקבל file_name באותה השלמה שהחיפוש עושה. פתק ריפו נושא גם orphaned: true כשהנתיב כבר אינו בעץ המשוקף, באותו כלל של codekeeper_list_repo_notes ומאותו קוד (repo_path_orphaned ב-mcp_server/backend.py): הדגל נדלק רק על תשובה מפורשת מהמניפסט, ושאילתה שנכשלה אינה מסמנת קובץ חי כמיותם.

זו אותה קריאה-לפי-מזהה של codekeeper_note_str_replace — get_note ב-mcp_server/backend.py — ולא מסלול שני. הבעלות יושבת במסנן של השאילתה, ולכן פתק של משתמש אחר הוא not_found, אותה תשובה כמו למזהה שאינו קיים.

הגוף חוזר בדיוק כפי שהוא מאוחסן. פתקים ישנים נשמרו עם ישויות HTML (&quot; במקום "); כלי הרשימה מפענחים אותן בקריאה, ואילו codekeeper_get_note — כמו codekeeper_get_note_version — אינו מפענח. כך ”הגוף של גרסה N“ ו“הגוף הנוכחי“ ניתנים להשוואה בית-בית, וגודל הגוף ברשימה (content_bytes, למטה) הוא בדיוק גודל מה שהכלי יחזיר. ובעריכה שתי הצורות תופסות: codekeeper_note_str_replace מפענח גם את old_string (_sanitize_note_text ב-mcp_server/handlers.py) ומשווה במרחב אחד, ולכן old_string שהועתק מ-codekeeper_get_note וגם כזה שהועתק מכלי רשימה מגיעים לאותה התאמה — יש על זה טסט שמריץ את שתי הצורות על פתק עם ישות, ובודק את המצב במסד אחרי העריכה.

הגוף והגרסה מתארים את אותו רגע, וזה נאכף ולא מונח. הגרסה יושבת באוסף אחר (sticky_note_versions) ולכן היא קריאה שנייה, ו-codekeeper_update_note שרץ בין השתיים היה מצמיד לגוף הישן את המספר של הגוף החדש — ואז codekeeper_get_note_version עם המספר ההוא היה מחזיר גוף אחר מזה שהתשובה נשאה (נתפס בסקירת ה-PR). לכן הקריאה תחומה משני הצדדים: הפתק נקרא, ההיסטוריה נקראת, והפתק נקרא שוב, והזוג מוחזר רק אם שום כתיבה לא נגעה בו בין השתיים — אותה הוכחה משולשת (תוכן, updated_at, write_id) שמסלול הכשל של update_note משתמש בה. פתק שזז נקרא שוב, עד _CONSISTENT_READ_RETRIES פעמים; פתק שזז בכולן — מישהו כותב בו ברצף — מחזיר conflict עם רמז, ולא זוג שאולי אינו תואם. ומה שהחלון הזה אינו סוגר, סוגר המספור עצמו: הצילום נכתב לפני הדריסה, ולכן בין השניים ההיסטוריה כבר מחזיקה את הגוף הנוכחי במספר N בעוד הפתק טרם זז — הגוף הזה כבר ניתן לקריאה כ-N, וזה מה שחוזר, לא N+1 (_version_of_body ב-mcp_server/backend.py). codekeeper_note_str_replace קורא בלי הגרסה, ולכן נשאר קריאה אחת.

הלולאה שהכלי סוגר. השער האופטימי של codekeeper_note_str_replace משווה את התוכן שנקרא בתוך הקריאה עצמה מול מה שבמסד (ראו ”היסטוריית פתקים“); updated_at ו-write_id משמשים רק להוכחה אחרי כשל כתיבה, ולא להשוואה. לכן מה שצריך כדי לנסות שוב אחרי conflict הוא הגוף הנוכחי — לבנות ממנו old_string חדש — וזה מה ש-codekeeper_get_note מחזיר: conflict ← codekeeper_get_note ← ניסיון חוזר.

ההרשאה נבדקת לפי סוג הפתק, לפני שתוכן כלשהו חוזר. פתק על קובץ ועל לוח — של הקורא בלבד, כמו בשאר הכלים. פתק על קובץ בריפו משוקף חסום לאדמין, כמו codekeeper_list_repo_notes. פתק של משתמש אחר ופתק ריפו למי שאינו אדמין מחזירים not_found — לא סירוב שמגלה שהמזהה קיים. הכלי אינו ב- _ADMIN_TOOLS: הוא גלוי לכולם, והשער בגוף. ורק True ממש פותח אותו — ערך ”אמיתי“ שאינו בוליאני נשאר סגור, כמו ב-_declares_write.

``include_content`` ברשימות. codekeeper_list_notes ו- codekeeper_list_board_notes מקבלים include_content: ברירת המחדל true מחזירה בדיוק מה שהכלים החזירו קודם, ו-false מחזיר לכל פתק רק את השדות של LEAN_NOTE_FIELDS (mcp_server/backend.py — הרשימה חיה שם פעם אחת, ותיאורי הכלים נגזרים ממנה): id, title, color, color_id, content_bytes ו-updated_at. content_bytes הוא גודל הגוף המאוחסן ב**בתים** של UTF-8. היחידה נקובה בשם השדה כי בפתק עברי ההפרש מול ספירת תווים הוא פי שניים, וזו היחידה שבה OUTPUT_BYTE_BUDGET נמדד. והגוף אינו יוצא מהמסד בכלל: המסלול הרזה הוא צינור אגרגציה (_lean_notes_pipeline ב-mcp_server/backend.py) שבו $strLenBytes מודד את הגוף במסד ו-$project מוריד אותו לפני המיון — ולא find שמושך עד NOTES_LIST_LIMIT גופים כדי לזרוק אותם, שזה מה שהגרסה הראשונה של ה-PR עשתה וסקירה תפסה. גוף שאינו מחרוזת (אף כותב אינו שומר כזה) מדווח null — ”לא ידוע“, לא ”ריק“ — ובזכות שמירת $type, ולא מפיל את השאילתה כולה כפי ש-$strLenBytes על מספר היה עושה. כך הזרימה ללוח גדול היא רשימה קטנה ואחריה codekeeper_get_note. המסלול המלא (include_content=true) נשאר find בלי היטלה, כי הוא מחזיר את הגוף — זו התנהגות כלי הרשימה מאז ומתמיד, והחריג היחיד לחוק ה-Smart Projection כאן: הגוף חוזר כשביקשו אותו, ולא נמשך כשלא. codekeeper_list_repo_notes נשאר בלי הפרמטר: קובץ בריפו חסום ל-MAX_NOTES_PER_REPO_FILE פתקים, והפער הוא בלוחות.

קריאה טהורה. readOnlyHint: true ורץ במאגר הקריאות, בלי יצירה של דבר — בשונה מ-codekeeper_list_boards, שמוצהר קריאה ובכל זאת יוצר לוח ברירת מחדל; שם זו הכרעה מנומקת ולא תקדים. ובדשבורד המדידה הכלי אינו נושא ck_read_mode כלל: התווית מתויגת רק לכלי קריאת קובץ שב-_TOOL_READ_MODE_PARAMS, וכלי שאינו במפה אינו נספר כ-full — ראו מצב הקריאה (ck_read_mode).

היסטוריית פתקים

codekeeper_update_note דורס את כל תוכן הפתק במה שנשלח. עד היום לא היה ממה לשחזר: שורה אחת שגויה מוחקת פתק בן 100 שורות, וכלי שרץ בלי אישור היה מוחק אותו בשקט.

לכן כל עדכון שמשנה את ה-content שומר את הגוף הקודם באוסף sticky_note_versions — לפני הדריסה. עדכון עם תוכן זהה אינו מייצר גרסה: כפילות הייתה דוחפת החוצה גרסה שעוד אפשר לשחזר, בתמורה לעותק של מה שכבר יש. הקריאה היא read-modify-write, ולכן הסדר אינו הפיך: codekeeper_note_str_replace נוסף אחרי ההיסטוריה ולא לפניה, שאם לא כן היה עוד מסלול שדורס בלי רשת.

codekeeper_note_str_replace נושא גם שער אופטימי: הדריסה מותנית בכך שהגוף במסד עדיין שווה למה שנקרא. שתי עריכות חופפות בלי השער היו מדווחות שתיהן הצלחה בזמן שהאחרונה מוחקת את עריכת הראשונה; עם השער המפסידה מקבלת conflict — קוראים את הפתק מחדש ב-codekeeper_get_note ומנסים שוב (ראו קריאת פתק בודד — codekeeper_get_note).

עובדה

פירוט

אוסף נפרד

ולא מערך מוטבע במסמך הפתק: שלוש פונקציות הרשימה קוראות בלי פרויקציה, ומערך היסטוריה היה נגרר לכל רשימת פתקים — הפרה של Smart Projection בשלושה מסלולים חמים

20 גרסאות לפתק

תקרת שימור. אחריה המקור נדחף החוצה, ולכן destructiveHint נשאר True: ההנחיה ללקוח מתארת את המקרה הגרוע, לא את הרגיל

כשל צילום עוצר את העדכון

snapshot_failed. דריסה אחרי צילום שנכשל היא בדיוק אובדן הנתונים שהמנגנון בא למנוע — רק אחרי שהובטחה רשת

דריסה שלא קרתה מוחקת את צילומה — רק בהוכחה

צילום שנכתב ואז הדריסה שאחריו לא התרחשה (מחיקה מקבילה, קונפליקט) נמחק בחזרה — גרסה שמתארת החלפה שלא הייתה היא עדות כוזבת. אבל חריגה אינה הוכחה: כשל רשת יכול ליפול אחרי שהשרת כבר כתב, ואז מחיקת הצילום היא בדיוק האובדן שהמנגנון מונע. ההוכחה היא קריאה חוזרת שמראה ש**שום** שדה לא זז — תוכן, updated_at, ו-write_id. תוכן לבדו אינו מספיק (כותב מקביל שמחזיר את הגוף הקודם — ABA — היה מתחזה ל“שום דבר לא קרה“), וגם חותמת אינה מספיקה: מונגו קוטם ל-מילישניות, ולכן שחזור באותה מילישנייה מחזיר גם אותה. write_id נוצר לקריאה אחת ולעולם אינו חוזר, ורק הוא מבדיל בוודאות בין ”הכתיבה שלנו נגעה“ לבין ”איש לא נגע“. בספק — הצילום נשאר

צילום דורש אינדקס מאומת

האילוץ הייחודי הוא מה שהופך מספר גרסה כפול לשגיאה שנתפסת; צילום בלעדיו היה מייצר היסטוריה שקרית בשקט. עד שהאינדקס מאומת, עדכוני תוכן נדחים ב-snapshot_failed — fail-closed, עם ניסיון בנייה חוזר

שורדת מחיקת פתק

הבעלות נשמרת במסמך הגרסה עצמו, ולכן ההיסטוריה נקראת גם אחרי שהפתק עצמו נעלם

השחזור הוא codekeeper_get_note_version ואז codekeeper_update_note עם התוכן שנקרא. אין כלי restore נפרד — הוא היה מסלול כתיבה רביעי לאותו שדה.

מי מחזיר את ההווה ומי את העבר. codekeeper_get_note מחזיר את הגוף הנוכחי ואומר ב-version באיזה מספר codekeeper_get_note_version מחזיר את הגוף הזה: מספר הגרסה החדשה ביותר אם היא כבר מחזיקה אותו (החלון שבין הצילום לדריסה), ואחרת המספר שהדריסה הבאה דרך השרת הזה תיתן לו. החישוב הוא _version_of_body ב-mcp_server/backend.py, והוא חולק עם הצילום (_snapshot_note) את שני חלקי הבניין של המספר — _newest_snapshot ו-_number_after — ולא עותק של החשבון. גוף ריק, שהצילום מדלג עליו, נושא null ולא מספר. ההבטחה מותנית בכך שהדריסה הבאה עוברת דרך השרת הזה: ההיסטוריה נכתבת רק במסלול ה-MCP (update_note ב-mcp_server/backend.py), ועריכה מהוובאפ אינה מוסיפה לה ואינה מזיזה את המספר — פתק שנערך בוובאפ בין הקריאה לדריסה הבאה יופיע במספר ההוא עם הגוף שהוובאפ השאיר, לא עם מה שנקרא. 1 הוא פתק שמעולם לא נדרס דרך השרת הזה. הגיזום אינו מזיז את המספר אף פעם, כי הגרסה החדשה ביותר אינה נגזמת לעולם.

התראה כשסוכן שומר קובץ

כל שמירת קובץ דרך ה-MCP — codekeeper_save_file, codekeeper_edit_file ו-codekeeper_append_file — שולחת למשתמש התראת Web Push. זה נועד למקרה שבו סוכן כותב בזמן שאיש אינו מסתכל: בלי ההתראה, הכתיבה מתגלה רק כשנכנסים.

המקור נקבע במיקום, לא בשדה. ה-hook יושב ב-ProductionBackend.save_file, ומחלקה זו מיובאת רק בשירות ה-MCP. כתיבה מהוובאפ או מהבוט אינה עוברת שם ולכן אינה יכולה לייצר התראה כזו, בלי שדה origin ובלי דגל מקור. שלושת הכלים מתנקזים לאותה מתודה, ולכן ה-hook אחד.

codekeeper_update_file_description אינו שולח התראה, וזו החלטה ולא השמטה: הוא אינו עובר ב-save_file כי הוא אינו שומר קובץ — לא נוצרת גרסה והתוכן אינו זז. התראה שאומרת ”סוכן שמר קובץ“ על שינוי שהמשתמש לא יזהה כשמירה היא רעש, ולא מידע.

חשוב

שירות ה-MCP אינו שולח את ההתראה. הוא רושם שורה באוסף push_events, ושולח הפוש שחי בתהליך הוובאפ אוסף אותה בסבב הבא. המשמעות המעשית: יש עיכוב של עד דקה, ובפריסה שבה רק שירות ה-MCP רץ ההתראות לא יישלחו כלל — הן יירשמו, ואינדקס TTL ינקה אותן אחרי שבוע.

  • כיבוי: MCP_PUSH_NOTIFICATIONS_ENABLED=false על שירות ה-MCP. הדגל נקרא בנקודת הרישום, ולכן כיבוי עוצר את הרישום עצמו ואינו מותיר תור שאיש אינו קורא.

  • תנאי מוקדם: למשתמש צריך להיות מנוי Push פעיל בדפדפן (נרשמים ב- /settings). בלעדיו האירועים נפסלים מיד ואינם נצברים.

  • כמה התראות: אחת לכל שמירה. שתי שמירות רצופות לאותו קובץ מגיעות כהתראה אחת שמתעדכנת, כי ה-tag מזהה את הקובץ ולא את הגרסה.

התיעוד המלא של המנגנון — האוסף, האינדקסים, תקרת הניסיונות ומה קורה כשמסירה נכשלת — יושב ב-עובדי Push.

פריימר לסוכן — GET /api/agent/primer

אנדפוינט HTTP רגיל (לא כלי MCP) שמחזיר טקסט חופשי שהסוכן קורא בפתיחת סשן. הוא חי על שירות ה-MCP, לא על הוובאפ:

https://<MCP-HOST>/api/agent/primer

לדוגמה: https://code-keeper-mcp.onrender.com/api/agent/primer.

אימות. אותו Authorization: Bearer <token> של שאר השירות — PAT מהבוט (/connect_claude) או טוקן OAuth. בלי טוקן תקין: 401.

תשובה. text/plain; charset=utf-8, לא JSON — הגוף נקרא על ידי המודל כפי שהוא. הוא מורכב משני חלקים:

  1. שדה ”הוראות לסוכן“ מעמוד ההגדרות בוובאפ (/settings) — עיקר התוכן.

  2. שורת מצב קצרה שנבנית בזמן אמת: שלושת הקבצים האחרונים שנשמרו ומתי.

התנהגות

פירוט

200

יש הוראות. הגוף = הוראות + שורת מצב (אם יש קבצים).

204

שדה ההוראות ריק. גוף ריק לחלוטין — הפריימר קיים כדי לשאת את ההוראות, ושמות קבצים בלי מסגור הם רעש ולא פריימר.

401

אין טוקן, או שהטוקן בוטל/פג.

תקרת 24KB

חריגה גורמת לחיתוך + שורה שמודיעה על כך; לעולם לא שגיאה, כי פריימר חתוך עדיף על פריימר חסר. שורת המצב מקבלת מקום שמור ולא נדחפת החוצה.

Cache

60 שניות לכל משתמש (Cache-Control: private, max-age=60). שינוי בהגדרות מתחיל להשפיע תוך דקה.

סינון סודות

כל הגוף עובר צנזור של דפוסים שנראים כמו מפתח (ghp_, ckmcp_, sk-, JWT, KEY=... וכו«) לפני ההחזרה — גם אם הודבקו לשדה בטעות. ערכי משתני סביבה של השרת אינם נכנסים לגוף כלל.

התקרה וה-TTL הם קבועים בקוד ולא משתני סביבה, במכוון: התקרה אינה כוונון תפעולי אלא תקציב מול חלון ההקשר של המודל. אילו היה משתנה סביבה, מצב הכשל של העלאתו היה בלתי-נראה — הקשר נאכל בשקט בלי שאף אחד מתריע. שינוי דורש PR.

הערה

הראוט מאמת בגוף שלו, ולא מסתמך על כך שהאפליקציה מוגנת. במצב OAuth ה-PATAuthMiddleware אינו מותקן כלל, וה-SDK עוטף רק את ה-mount של /mcp — ולכן ראוט שנרשם ידנית (כמו /healthz) מוגש ללא אימות. כל ראוט חדש שאסור שיהיה ציבורי חייב לקרוא ל-authenticate_bearer בעצמו. ראו mcp_server/auth.py.

עריכה מהדפדפן

/settings → הכרטיס ”הוראות לסוכן“. השדה מציג מונה בייטים חי ומתריע כשמתקרבים לתקרה (מ-21KB) וכשחוצים אותה — כדי שחריגה תיתפס בכתיבה, ולא רק דרך שורת חיתוך שמופיעה בגוף שרק הסוכן קורא.

האחסון (48KB תווים) רחב מההגשה (24KB) בכוונה: החיתוך הוא החלטה של ההגשה ולא של האחסון, כדי שלא יימחק טקסט שהמשתמש כתב. הסודות נשמרים כפי שהם ומסוננים רק בהגשה — אחרת המשתמש היה פותח את ההגדרות ומוצא ***REDACTED*** בלי לדעת מה נמחק לו.

אזהרה

הוק שמושך את הפריימר בפתיחת סשן צריך להצביע על ה-MCP host. הצבעה על הוובאפ מחזירה 404, ואם ההוק שותק בכל כשל — הוא ייכשל לנצח בלי לומר מילה. הבחינו בין 204 (אין פריימר — שתיקה נכונה) לבין 404/401 (תקלה — ראוי להשמיע קול פעם אחת).

חשוב

פלאגין לא נטען בסשן שרץ בדפדפן, ולכן ההוק מחויב לריפו. קונטיינר של סשן Claude Code בדפדפן עולה עם SKIP_PLUGIN_MARKETPLACE=true, ולכן שום פלאגין מהמרקטפלייס לא נפרס לתוכו: אין תיקיית plugins, אין CLAUDE_PLUGIN_ROOT, וההוק שהפלאגין נושא לא רץ. ומכיוון שלא רץ, הוא גם אינו יכול לדווח על כך — השתיקה נראית בדיוק כמו 204.

שלוש בדיקות מפרידות בין המצבים: הכלי ListPlugins מחזיר רשימה ריקה למרות שהפלאגין דולק בהגדרות, הסקריפט של הפלאגין לא נמצא בדיסק, ו-CLAUDE_PLUGIN_ROOT ריק.

הדגל מוזרק על ידי הרנר של הסביבה ואינו ניתן לכיבוי מצד החשבון — הוא מופיע ברשימה שה-environment-manager רושם ביומן תחת startup_context_env_var_keys, לצד CLAUDE_CODE_REMOTE ו-CLAUDE_CODE_SYNC_SKILLS, במקום שבו משתנים שהמשתמש הגדיר אינם מופיעים. השכן ברשימה הוא כל התמונה: CLAUDE_CODE_SYNC_SKILLS=1 — סקילים כן מסונכרנים לסביבות האלה, פלאגינים לא.

לכן .claude/settings.json ו-.claude/hooks/codekeeper-primer.sh יושבים בריפו הזה ולא נשענים על הפלאגין. הראיות המלאות: codekeeper-plugin#3.

נבדק ב-13 באוגוסט 2026 בסביבת cloud_default. זו התנהגות של הפלטפורמה ולא חוזה יציב — שווה לאמת מחדש אם הפריימר מתחיל להיטען לבד דרך הפלאגין.

הפעלה — צעד אחר צעד

שלב 1 — שירות חדש

שירות web נפרד (ASGI) ב-Render, שמתחבר לאותו MongoDB של הבוט והוובאפ:

Start command:  uvicorn mcp_server.app:app --host 0.0.0.0 --port $PORT
Health check:   /healthz

שלב 2 — מצב בסיס (PAT בלבד)

מספיק כדי לעבוד מול Claude Code / Desktop:

משתנה

הערה

MONGODB_URL + DATABASE_NAME

זהים לבוט/וובאפ

BOT_TOKEN

נדרש רק לטעינת מודול ה-config המשותף

שלב 3 — מצב OAuth (מוסיף את Claude.ai)

נדלק אוטומטית כשמוגדרים בשירות ה-MCP:

משתנה

הערה

MCP_SERVER_URL

ה-URL הציבורי (https) של שירות ה-MCP

WEBAPP_URL

ה-URL הציבורי של הוובאפ (למסך התחברות הטלגרם)

SECRET_KEY

זהה לוובאפ, ערך אקראי חזק (≥16 תווים) — חותם את זהות המשתמש בין השירותים

בנוסף: על הוובאפ להגדיר MCP_SERVER_URL (+אותו SECRET_KEY), ועל הבוט MCP_SERVER_URL (לפקודת /connect_claude).

שלב 4 — דפדפן הריפו (אדמין, אופציונלי)

משתנה

הערה

ADMIN_USER_IDS

מזהה הטלגרם של האדמין (CSV). ריק = הכלים כבויים לכולם

REPO_MIRROR_PATH + דיסק מצורף

ב-Render דיסק הוא פר-שירות; בלי דיסק ה-mirrors משוכפלים מחדש אחרי כל deploy

GITHUB_TOKENS / GITHUB_TOKEN

רק לריפואים פרטיים (אימות ל-clone/fetch)

אין צורך ב-GITHUB_WEBHOOK_SECRET בשירות ה-MCP — ה-webhook ממשיך להגיע לוובאפ בלבד, וה-MCP מתעדכן לבד (ראו ”רענון אוטומטי“ למטה).

הרשימה המלאה של המשתנים: משתני סביבה - רפרנס.

חיבור לקוחות

Claude.ai (Custom Connector)

Settings → Connectors → Add custom connector → הזינו את הכתובת:

https://<mcp-host>/mcp

זהו — Claude מבצע DCR + OAuth לבד, מפנה להתחברות טלגרם ולמסך אישור. אין צורך ב-Client ID/Secret. כדי לקבל write, ה-connector צריך להירשם עם ההרשאה — אם כבר חיברתם לקריאה בלבד, הסירו והוסיפו מחדש ואשרו ”קריאה וכתיבה“.

Claude Code (טוקן)

שלחו לבוט בצ’אט פרטי /connect_claude (או /connect_claude write), ואז:

claude mcp add --transport http codekeeper https://<mcp-host>/mcp \
  --header "Authorization: Bearer <token>"

Claude Desktop

ב-claude_desktop_config.json:

{
  "mcpServers": {
    "codekeeper": {
      "type": "http",
      "url": "https://<mcp-host>/mcp",
      "headers": { "Authorization": "Bearer <token>" }
    }
  }
}

דפדפן הריפו — איך זה עובד

רענון אוטומטי (autosync)

שירות ה-MCP מריץ thread רקע (אותו דפוס כמו ה-worker בוובאפ) ששומר על ה-mirrors המקומיים טריים — בלי cron ובלי שירות נוסף:

merge ל-main → GitHub webhook → הוובאפ מסנכרן את הדיסק שלו וכותב SHA ל-Mongo
            → ה-autosync ב-MCP מזהה שה-SHA המקומי שונה → git fetch מקומי
  • ריפו שקיים ב-repo_metadata אך חסר בדיסק המקומי — משוכפל אוטומטית (אין צורך ב-import ידני בצד ה-MCP).

  • שליטה: MCP_REPO_AUTOSYNC (ברירת מחדל פעיל), MCP_REPO_AUTOSYNC_INTERVAL (ברירת מחדל 300 שניות).

מדיניות סינון סודות

נתיבים רגישים (.env*, *.pem, *.key, id_rsa*, secrets.*, credentials* ועוד) נחסמים בקריאת קובץ, מושמטים מרשימות ו**מדולגים** בחיפוש — בכל הריפואים, תמיד. ההתאמה case-insensitive על הנתיב המלא ועל כל רכיב בו, ולכן שם רגיש נתפס גם כשהוא תיקייה ולא קובץ: config/.env נחסם, וגם config/.env/README.md שבתוכה. הרחבת הרשימה: MCP_REPO_DENYLIST_EXTRA (CSV של תבניות glob). על שגיאה פנימית המדיניות נכשלת סגור (חוסמת).

בחיפוש ההחרגה נכנסת ל-git grep עצמו, ולכן קובץ חסום אינו נסרק ואינו נספר — ולא רק מסונן מהתוצאות. התבניות הן אותן תבניות; הסינון על התוצאות נשאר כשכבה אחרונה.

קוד חיצוני וקבצים מקומפלים הם רשימה אחרת לגמרי, ולא להתבלבל. סוד חסום גם בקריאה; node_modules ובאנדלים רק אינם נכללים בחיפוש כברירת מחדל, וממשיכים להיקרא דרך codekeeper_get_repo_file. ראו כמה מופעים יש באמת (total מול total_at_least).

והכלי הציבורי אינו יוצא מן הכלל. codekeeper_docs_get_section הוא הכלי היחיד כאן שאינו דורש אדמין, והוא קורא דרך אותה פונקציה — ולכן המדיניות חלה עליו במלואה. path="secrets" מחזיר path_denied ולא not_found, ובריפו שהתיעוד שלו יושב בשורש גם secrets.md ו-credentials.md חסומים. ההבדל בין שני הקודים אינו סגנוני: not_found אומר ”אין קובץ כזה“ ומזמין לנסות שם אחר, ו-path_denied אומר ”יש, ולא תקבל“.

ועוד שני סירובים שנוקבים בסיבתם, משתי סיבות שהתחזו לאחרות (#3432). path_outside_root — הנתיב תקין בצורתו ופותר אל מחוץ לשורש התיעוד של הריפו (docs/../secrets, /etc/hosts, ../README.md בריפו ששורשו ריק); התשובה נושאת את root, שממילא כתוב בתיאור הכלי. עד היום זה חזר כ-missing_path, והקורא לא ידע אם שכח נתיב או ניסה לצאת מהשורש; missing_path נשאר לנתיב ריק בלבד. ו-repo_not_mirrored — למארח אין עותק של הריפו כלל (הכלי codekeeper_get_repo_file מחזיר אותו באותו מצב). עד היום גם זה חזר כ-not_found, וסוכן שביקש עמוד אמיתי ניסה שם אחר בזמן שהבעיה הייתה אצל המפעיל. אם sync רץ באותו רגע, התשובה היא sync_in_progress כמו תמיד.

התנהגות בזמן sync

אין נעילת קריאה מול ה-sync, ולכן קריאה שנכשלת בזמן ש-sync רץ מחזירה:

{"ok": false, "error": "sync_in_progress", "retry_after": 30}

זה סימן לנסות שוב אחרי המתנה קצרה — לא להסיק שהקובץ או הריפו לא קיימים.

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

חשוב

הכניסה הראשונה ב-``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.

הסירובים שמגיעים מהפרסור עצמו, וכולם error ולא כשל כללי:

inconsistent_line_endings

בקובץ יש \r שאינו חלק מ-\r\n — הפורמט של Mac שלפני 2001. שם שכבר קיים באוצר המילים: outline מחזיר אותו כ-reason על אותה מחלקת קלט בדיוק. בקובץ כזה ספירת השורות אינה חד-משמעית, ומפה שמצביעה לשורה שאי אפשר לבקש גרועה ממפה שאין. CRLF עובד במלואו.

too_many_lines

קובץ Markdown ארוך מ-MAX_LINES שורות (services/md_parser.py; הערך ב-הקבועים, התקרות ואוצר המילים של התשובה). נבדק לפני הפרסור, ולכן הסירוב אינו עולה דבר. התשובה נושאת max — התקרה — ו-total_lines, מספר השורות באותה יחידה כמו כל שדה lines בתשובות ה-MCP; line אין בה, כי הפרסור לא התחיל. קובץ כזה אפשר לקרוא בטווחים, ב-codekeeper_get_repo_file עם lines. למה תקרת שורות ולא רק תקרת טוקנים: חלק מהזיכרון של markdown-it-py נגזר ממספר השורות ולא ממספר הטוקנים — מערכים עם ערך לכל שורה שנבנים לפני שכלל כלשהו רץ, הגדרות קישור שנשמרות בלי טוקן, וציטוט מקונן ששומר רשימות לכל שורה בכל רמה. קובץ כזה יכול לעלות את כל ההקצאה לחוט עם כמה עשרות טוקנים בלבד.

too_many_tokens

הפרסור הגיע ל-MAX_TOKENS טוקני בלוק (services/md_parser.py; הערך ב-הקבועים, התקרות ואוצר המילים של התשובה). התקרה נבדקת ברגע שנוצר כל טוקן — גם באמצע טבלה ובאמצע רשימה, שנבנות בקריאה אחת לכלל של markdown-it-py — ולכן הפרסור נעצר באמצע, ולא אחרי שכל העבודה כבר נעשתה. התשובה נושאת max ואת line, השורה שהפרסור הגיע אליה (בלי line כשעוד לא נוצר טוקן שיש לו מיקום). למה היא נחוצה גם אחרי השדרוג ל-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, ובחוזה המשותף של שני הפרסרים.

כשהפארסר מספק מספר שורה, התשובה נושאת גם 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.

ראו גם מדיניות סינון סודות — מדיניות הסודות חלה על הכלי הזה במלואה.

איפה מפת ה-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).

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

אזהרה

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

section = ``codekeeper_get_file``     ← מוצא
section = codekeeper_get_file         ← לא מוצא, ונראה כמו באג

זה נוגע להרבה כותרות בעמודי התיעוד כאן. הדרך הבטוחה: קריאה בלי section מחזירה את עץ הכותרות, ומשם מעתיקים את השם המדויק.

וכששאילתה אינה נמצאת, suggestions מחזיר קודם כול כותרת שקרובה למה שהוקלד, כשיש כזו בעמוד. ורק כשאין אף כותרת קרובה הוא מחזיר במקומה את המזהים שכן קיימים באותו עמוד. כלומר שאילתה בצורת מזהה יכולה לחזור דווקא עם כותרת, ואין לקרוא את השדה כאילו הוא תמיד רשימת מזהים.

הסדר הזה מכוון, ולא מקרי: מחרוזת יכולה להיות בצורת מזהה בלי להיות מזהה בעמוד הזה — H2 הוא אות וספרה, ובעמוד שיש בו כותרת H2O זו הכותרת שתחזור. כותרת אמיתית שהקורא יכול להעתיק היא תשובה טובה יותר מרשימת מזהים שאינה קשורה לשאלה.

והכמות: עד 50 הצעות. זה נכון לשני המקרים — כותרת קרובה או רשימת מזהים — ואם היו עוד, התשובה נושאת suggestions_truncated: true. הדגל אומר ”היו עוד וחתכנו“, ולא משנה איזה משני המסלולים החזיר את התשובה.

ולמה דווקא 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"}

תחביר שבור, פייתון 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 קטן יותר; אין כאן חיתוך שקט

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

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

חשוב

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.

קלט

התנהגות

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

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

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), ולכן כל קורא בשרשרת מעביר את הערך במפורש ואינו נשען על אף אחת מהן.

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

תקציב הבתים של התשובה כולה נמדד על הסריאליזציה האמיתית ב-UTF-8, ולא בספירת תווים ולא בהערכה פר-רשומה.

גודל וספירת שורות ברשימת הריפו

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

truncated: true בתשובה אומר שהעמוד נחתך בתקציב הפלט. עם include_stats הרשומות שמנות יותר, ולכן החיתוך עשוי לקרות מוקדם יותר מאשר בקריאה רגילה — 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"},
   "result": {"ok": false, "error": "not_found"}}
]}
  • הסדר הוא סדר הבקשה, ו-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 בא כל פריט בלי להכיר את צורת התשובה של כל כלי. פריט שלא נקרא בו תוכן (סירוב, קובץ שלא נמצא) אינו נושא resolved_commit.

אזהרה

כשהקיבוע נכשל באופן זמני, פריטים של אותו ריפו יכולים להגיע מ-commits שונים. אם resolve_commit עונה timeout או internal_error, השרת רושם WARNING אחד לריפו, והפריטים שלו נקראים לפי שם הענף — כל אחד פותר אותו בנפרד, כמו הכלי הבודד. autosync שמושך באותו רגע יכול אז לפצל אותם בין שני commits. זה לא נמנע, אבל זה גלוי: resolved_commit של כל פריט אומר מאיזה commit הוא בא, ומי שצריך commit אחד בודק שכולם שווים. קוד שאומר ”אין כזה“ (invalid_ref, repo_not_found) אינו נרשם: הוא חוזר מהקריאה עצמה בשמו, בדיוק כמו בכלי הבודד.

קריאה אחת לכל קובץ, ושני סדרים

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

מדידת שימוש (PostHog MCP Analytics)

השרת מדווח ל-PostHog אירוע על כל קריאת כלי, על כל tools/list ועל כל לחיצת יד — כדי לדעת באילו כלים באמת משתמשים, כמה זמן הם לוקחים וכמה מהם נכשלים. הקוד: mcp_server/analytics.py.

הסעיף הזה מתאר את צד ה**כתיבה**: מה השרת שולח, ומה נחסם בשער הפרטיות שלפניו. לקריאת הנתונים חזרה יש מסך אדמין ייעודי בוובאפ — /admin/mcp — עם משתני הסביבה שהוא דורש ומצבי הכשל שלו: MCP Analytics (מדידת השימוש בכלי ה-MCP). שימו לב ש-POSTHOG_HOST נושא שם משותף וערך שונה בין שני השירותים, והפרטים שם.

מה לא יוצא מהשרת. בשרת הזה הארגומנטים של הכלי והתוצאה שלו הם התוכן של המשתמש: codekeeper_get_file מחזיר גוף של קובץ, codekeeper_save_file מקבל אחד, ואותם מסלולים משרתים גם פתקים ומסמכים משוקפים. לכן הוק before_send על לקוח ה-PostHog מחזיק רשימת היתר של מאפייני $mcp_*, וכל מאפיין שאינו בה נזרק:

מאפיין

מה קורה איתו

$mcp_parameters

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

$mcp_response

נזרק, בכל אירוע ובלי חריג. תוצאת הכלי — גוף קובץ, פתק, מסמך

$mcp_error_message

נשלח, כשהערך מחרוזת, ואחרי סינון סודות. הודעת החריגה של קריאה שנכשלה, שבלעדיה הדשבורד יודע שמשהו נדחה ולא איזה שדה. ראו את האזהרה מתחת לטבלה — גם אחרי הסינון ההודעה עלולה לשאת קטע מהקלט שנדחה

$mcp_intent

נשלח, כשהערך מחרוזת, ואחרי סינון סודות. המשפט שהסוכן כותב על מה שהוא מנסה להשיג. ראו ”לכידת הכוונה“ למטה — הוא נוצר רק כי context דלוק

$mcp_intent_source

נשלח, כשהערך הוא בדיוק context_parameter או inferred. זו תווית סגורה שה-SDK כותב, לא טקסט — ולכן היא נאכפת מול שני הערכים ולא מתקבלת כמחרוזת חופשית

$exception_list[*].value

מוחלף בטקסט קבוע, בכל אירוע. לא אותו מידע כמו $mcp_error_message: זה נושא את שרשרת ה-__cause__ המלאה ובלי תקרה, בעוד המאפיין נושא את הרשומה הראשית בלבד, מקוצצת ל-2,048 תווים בידי ה-SDK. וזה לא נעצר בכלים: enable_exception_autocapture תופס גם threading.excepthook, כך שחריגה שלא נתפסה ב-thread כלשהו בתהליך (למשל ה-autosync) מגיעה כאירוע $exception בלי שום מפתח $mcp_ להיאחז בו. סוג החריגה והמסגרות נשמרים, ולכן קיבוץ השגיאות עובד

ck_read_mode

נשלח, כשהערך אחד מארבעה: outline, query, range או full. מאפיין שהשרת מחשב בעצמו — ראו ”מצב הקריאה“ למטה

$mcp_tool_name · $mcp_duration_ms · $mcp_is_error · $mcp_error_type · $mcp_client_name · $session_id · $mcp_listed_tool_names

נשלחים. מטא-דאטה של השרת ושל הפרוטוקול בלבד

הלקוח הזה משרת רק את שרת ה-MCP, וכל מה שהשרת נוגע בו הוא תוכן של המשתמש. לכן הכלל חל על כל אירוע שהלקוח שולח, ולא רק על אירועי $mcp_.

שני שדות של טקסט חופשי, ובדיקה אחת שחלה על שניהם. $mcp_error_message ו-$mcp_intent הם היחידים שיוצאים ואינם נכתבו על ידי השרת. שניהם מוצדקים כ“משפט שאדם קורא“, ולכן שניהם עוברים רק כשהערך הוא מחרוזת: מילון או רשימה תחת אותו שם אינם המשפט הזה אלא מבנה שלם — ומבנה אי אפשר לחטא חלקית, ולכן הוא נזרק. זה נמדד ולא נוסח: מילון תחת $mcp_error_message עבר גרסה קודמת של השער, שבדקה מי מבקש ולא מה עובר, עם גוף קובץ בתוכו.

ואז שניהם עוברים סינון סודות. אותה רשימת דפוסים שמסננת את הפריימר, ב-mcp_server/redaction.py — מודול טהור ששני הצרכנים מייבאים, ולא עותק שני. זה לא סגנון: כשלכל צרכן הייתה רשימה משלו, דפוס נוסף לאחד ולא לשני, וזה כבר קרה בפרויקט הזה.

מה נמדד שהמסנן תופס, ומה לא

נתפס

לא נתפס

כל צורת סוד מוכרת, בכל מקום במחרוזת: מפתח AWS בזנב של input_value, ghp_, ckmcp_, sk-, JWT, Bearer, ו-scheme://user:pass@host

הכלל לפי שם (API_KEY=…) כשההשמה יושבת באמצע שורה — וזו בדיוק הצורה של הודעת Pydantic. הכלל מעוגן ל-^, וזה מכוון: בלי העיגון key=value בתוך פרוזה היה נמחק מהפריימר

—

נתיבי קבצים ושמות. הם אינם סודות, והמסנן אינו מתיימר להיות מנקה PII

המסנן מצמצם חשיפה; הוא אינו הופך את השדות לבטוחים. האזהרה שמתחת נשארת תקפה במלואה.

אזהרה

הודעת השגיאה עלולה לשאת קטע מתוכן המשתמש, וזה התקבל במודע.

ההודעה של Pydantic מכילה את input_value — הערך שנדחה. הוא מקוצץ ל-50 תווים, אבל הקיצוץ שומר ראש וזנב, ולכן ערך רגיש בתחילת הקלט או בסופו עובר שלם. נמדד על pydantic 2.12.3: קלט בן 500+ תווים שהסתיים ב-AWS_KEY=... החזיר את הזנב במלואו. והמקרה הרחב יותר הוא שדה חובה חסר (type=missing), שבו Pydantic מדווח כ-input_value את אובייקט הארגומנטים כולו — כלומר codekeeper_save_file בלי file_name שם קטע מגוף הקובץ בהודעה.

וזה כבר לא מוגבל ל-Pydantic. בגרסה קודמת ההודעה נשלחה רק כאשר $mcp_error_type היה בדיוק ValidationError; הגידור הזה הוסר, ולכן הודעה של חריגה מכל סוג יוצאת — כולל חריגה שספרייה זרקה. חריגת pymongo יכולה לנקוב בכותרת שחיפשנו, ושגיאת מערכת יכולה לנקוב בנתיב. מה שמצמצם את זה בפועל אינו השער אלא הקוד: repo_backend.py תופס רחב ומחזיר {"ok": false, "error": ...} במקום לזרוק, ולכן רוב הכשלים לעולם אינם מגיעים לכאן כחריגה. זו תכונה של הקוד היום, לא הבטחה של השער — מסלול חדש שנותן לחריגה לעלות מרחיב את מה שיוצא, בלי לגעת ב-analytics.py.

הגידור הוא בהיקף ולא בניקוי: אין כאן ניסיון לחטא את המחרוזת, כי סניטציה מומצאת על טקסט חופשי נכשלת בשקט. האורך מגודר על ידי ה-SDK, שמקצץ את $mcp_error_message ל-2,048 תווים.

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

לכידת הכוונה (context)

$mcp_intent אינו נוצר מעצמו. posthog.mcp מייצר אותו רק כשהאופציה context דלוקה — או כשמוגדר intent_fallback, שאינו מוגדר כאן — ולכן פתיחת המאפיין בשער לבדה לא הייתה משנה דבר. האופציה דלוקה.

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

מה שהמדידה הראתה, ומרגיע: קריאה שאינה מעבירה context אינה נדחית. הכלי רץ בדיוק כמו קודם, והפרמטר המוזרק אינו מגיע לגוף הכלי כלל — ה-SDK מסיר אותו לפני ההפעלה. כלומר לקוח קיים אינו נשבר; מה שהשתנה הוא מה שרשימת הכלים מצהירה, ושסוכנים מתבקשים למשפט שלא התבקשו לו קודם.

התיאור של הפרמטר הוא שלנו ולא ברירת המחדל של ה-SDK, משתי סיבות: ברירת המחדל היא בלוק ארוך שצועק YOU MUST provide 15-25 words (count carefully), והוא מוגש עם כל כלי; והטקסט הזה הוא המקום היחיד שבו סוכן נאמר לו מה לא לשים בשדה שמגיע עכשיו ל-PostHog. זו הנחיה למודל ולא גבול שהשרת אוכף, והשער כתוב מתוך הנחה שאפשר להתעלם ממנה.

מצב הקריאה (ck_read_mode)

outline=true ו-lines=[5, 80] הן אותו כלי, ולכן דשבורד שסופר לפי שם הכלי סופר אותן באותה עמודה — בזמן שהן הפוכות במשמעות: אאוטליין הוא מפה זולה, וקריאת תוכן היא הניווט היקר שהמפה באה להחליף.

ההבחנה חיה ב-$mcp_parameters, והוא חסום ונשאר חסום. לכן במקום לפתוח את הארגומנטים, השרת מחשב בעצמו תווית אחת מתוך קבוצה סגורה ושולח אותה:

ערך

מתי

outline

הועבר outline אמיתי לכלי שמצהיר עליו. גם קריאה שמעבירה גם lines (ונדחית כ-outline_and_lines) נספרת כאן — היא לא קראה תוכן

query

הועבר query לכלי שמצהיר עליו — מופעים במקום תוכן, כלומר קריאה זולה מאותה משפחה כמו outline. נבדק לפני lines, ולכן גם קריאה שמעבירה את שניהם (ונדחית כ-query_and_lines) נספרת כאן, מאותו נימוק

range

הועבר lines

full

לא הועבר אף אחד מהם — קריאת הקובץ המלא, היקרה מכולן

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

הערה

כל פרמטר נקרא רק עבור הכלי שמצהיר עליו. outline קיים ב- codekeeper_get_repo_file בלבד, ו-query ב-codekeeper_get_file בלבד. הקולבק מקבל את מילון הארגומנטים הגולמי — לפני ש-pydantic מסלק ממנו מפתחות שאינם בסכימה של הכלי — ולכן מפתח תועה של הכלי האחר מגיע אליו ונקרא. בלי השיוך, קריאה כזו נספרת בעמודה הלא נכונה ושתי העמודות האחרות יוצאות חסרות באותה מידה, בלי שגיאה ובלי סימן. השיוך יושב ב-_TOOL_READ_MODE_PARAMS ב-mcp_server/analytics.py, ו-FILE_READ_TOOLS נגזר ממנו ואינו נכתב פעמיים.

מאותה סיבה codekeeper_search_code, שנושא פרמטר בשם query ואינו קריאת קובץ, אינו נכנס לעמודה הזו כלל: הוא אינו במפה.

גם ``codekeeper_read_batch`` אינו במפה, בהחלטה. lines ושאר הפרמטרים שהקולבק מחפש יושבים אצלו בתוך הפריטים ולא ברמה העליונה, ובאץ« אחד מערב קריאה מלאה, טווח וסעיף — אין לו מצב קריאה אחד לתייג. תווית לכל פריט הייתה מחייבת לפתוח את items לקולבק, כלומר לקרוא את הארגומנטים ש-$mcp_parameters חוסם בכוונה. מה שנשלח עליו הוא שם הכלי והמשך בלבד, ולכן באץ« הוא אירוע אחד בדשבורד, ולא אירוע לכל פריט (ראו קריאה קבוצתית — codekeeper_read_batch).

התווית נוספת רק לקריאת כלי מבין הכלים שמקבלים lines. ה-SDK מריץ את הקולבק על כל אירוע, וגרסה שנפלה ל-full בברירת מחדל תייגה גם את לחיצת היד ואת tools/list — כלומר ניפחה בדיוק את העמודה שהמאפיין נבנה כדי למדוד.

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

מה כן דלוק: ``report_missing``. היא מוסיפה כלי וירטואלי אחד, get_more_tools, שהסוכן קורא לו כדי לומר איזו יכולת חסרה לו. היא אינה נוגעת באף כלי קיים, וגם הסינון של כלי האדמין ממשיך לעבוד (get_more_tools מתווסף אחרי הסינון ולכן גלוי לכולם, כפי שהוא אמור להיות). המשפט שהסוכן כותב שם מגיע כ-$mcp_intent על אירוע $mcp_missing_capability — אותו מאפיין שעובר היום גם על קריאת כלי רגילה.

קונפיגורציה. שני משתני סביבה בשירות ה-MCP: POSTHOG_PROJECT_TOKEN ו-POSTHOG_HOST. חסר אחד מהם — בפרודקשן השרת עולה רגיל והמדידה פשוט לא פועלת (עם אזהרה בלוג), ובסביבת פיתוח העלייה נכשלת ברעש, כדי ש“אין אירועים“ לא ייראה כמו ”הכל תקין“. ”פרודקשן“ הוא ENVIRONMENT/ENV ששווה production או prod, וכן המצב שבו אף אחד מהם אינו מוגדר; כל ערך אחר נחשב פיתוח. הטוקן לעולם אינו בקוד ואינו בריפו.

הערה

כשהמדידה כבויה (אין טוקן, אין host) האינסטרומנטציה אינה רצה כלל — ולכן גם context אינו מוזרק והסכימות של הכלים זהות לאלה שלפניה. זה מה שמסביר למה סוויטת הבדיקות רואה סכימות נקיות: היא רצה בלי קונפיגורציה של PostHog.

ניקוז. posthog.shutdown רשום ב-atexit, ובנוסף ה-lifespan של אפליקציית ה-ASGI מנקז את האירועים שעדיין באוויר לפני שהלולאה של uvicorn נסגרת. atexit לבדו אינו מספיק: הלכידה האוטומטית היא asyncio.Task על אותה לולאה, ומשימה שנשארה תלויה כשהלולאה נסגרת לא מגיעה בכלל לתור השליחה.

אבטחה — עקרונות

  • זהות תמיד מהטוקן; טוקנים נשמרים כ-hash בלבד ומוצגים פעם אחת.

  • ראוט שנרשם ידנית (לא /mcp) אינו מוגן במצב OAuth — הוא חייב לקרוא ל-authenticate_bearer בגוף שלו. ראו פריימר לסוכן — GET /api/agent/primer.

  • קוד הרשאה ו-refresh token חד-פעמיים (זיהוי replay); refresh לא מרחיב הרשאות.

  • SECRET_KEY חלש/ריק/ברירת-מחדל — מצב OAuth מסרב לעלות (fail-closed).

  • מסך האישור מציג במדויק קריאה בלבד לעומת קריאה וכתיבה.

  • כלי האדמין: הסתרה מ-tools/list היא נוחות בלבד — האכיפה האמיתית היא בגוף כל כלי.

פתרון תקלות

סימפטום

כיוון

401 invalid_token

הטוקן בוטל/פג או ששירות ה-MCP מחובר ל-MongoDB אחר מזה שהנפיק אותו

404 על /api/agent/primer

ה-URL מצביע על הוובאפ במקום על ה-MCP host — האנדפוינט חי רק על ה-MCP

204 על /api/agent/primer

תקין, לא תקלה: שדה ”הוראות לסוכן“ ריק. מלאו אותו ב-/settings

הפריימר לא נטען, בלי שום הודעה

אם ההוק מגיע מפלאגין: בסשן בדפדפן פלאגינים אינם נטענים כלל ולכן הוא לא רץ. ListPlugins יחזיר רשימה ריקה. ראו את ההערה בסעיף פריימר לסוכן — GET /api/agent/primer

הפריימר לא מתעדכן

ה-cache הוא 60 שניות — המתינו דקה אחרי השמירה

421 Invalid Host header

MCP_ALLOWED_HOSTS מגדיר host שלא תואם; ריק = הבדיקה כבויה

bad_assertion במסך האישור

SECRET_KEY לא זהה בין הוובאפ לשירות ה-MCP

insufficient_scope בשמירה

חיבור בקריאה בלבד — חברו מחדש עם write (או /connect_claude write)

file_exists בשמירה

כבר קיים קובץ בשם הזה, ו-codekeeper_save_file אינו דורס: שמירה כזו הייתה יוצרת גרסה חדשה, והתוכן הקודם היה נעלם מהחיפוש ומעמוד הקובץ. לעריכה — codekeeper_edit_file / codekeeper_append_file, או שמרו בשם אחר. ואם רק התיאור הוא מה שהתיישן — codekeeper_update_file_description

description_too_long בעדכון תיאור

התיאור חורג מהמגבלה, וההודעה נושאת אותה ב-max. הוא נדחה ולא נחתך בכוונה — ראו עדכון תיאור בלי גרסה חדשה

not_found בעדכון תיאור

אין קובץ פעיל בשם הזה אצל המשתמש המאומת. השם נלקח כפי שהוא מופיע ב-codekeeper_list_files

existence_check_unavailable בשמירה

בדיקת הקיום נכשלה — זה אינו ”הקובץ אינו קיים“. שום דבר לא נשמר; נסו שוב בעוד רגע. אותה הבחנה כמו ב-repo_list_unavailable

admin_only בכלי ריפו

ה-user_id אינו ב-ADMIN_USER_IDS בשירות ה-MCP. חל גם על codekeeper_list_repo_notes / codekeeper_create_repo_note

repo_not_found ביצירת פתק ריפו

שם הריפו אינו ברשימת המשוקפים. codekeeper_list_repos מציג את השמות המדויקים

repo_file_not_found ביצירת פתק ריפו

הנתיב אינו בעץ המשוקף. הנתיב נלקח כפי שהוא מופיע ב-list_repo_tree

repo_list_unavailable / repo_file_unavailable

השאילתה על המניפסט נכשלה — זה אינו ”לא קיים“. אל תתקנו את הנתיב; נסו שוב

collection_not_found בשיוך לאוסף

ה-collection_id אינו קיים או אינו שלך. codekeeper_list_collections מציג את המזהים

file_not_found בשיוך לאוסף

השם אינו של קובץ שמור. השיוך אינו יוצר קובץ — שמרו קודם ב-save_file

snapshot_failed בעדכון פתק

צילום הגרסה הקודמת נכשל, ולכן העדכון לא בוצע. הפתק שלם; נסו שוב

version_not_found

מספר הגרסה אינו בטווח שנשמר (20 האחרונות). codekeeper_list_note_versions מציג מה קיים

ambiguous_match ב-note_str_replace

old_string מופיע יותר מפעם אחת. הארוכו, או העבירו replace_all

conflict ב-note_str_replace

הפתק השתנה בין הקריאה לכתיבה (עריכה מקבילה). קראו אותו מחדש ונסו שוב — שום דבר לא נדרס

duplicate_title בפתק

השם כבר תפוס — על אותו לוח, או על אותו קובץ בריפו

invalid_line_range בקריאת קובץ

lines אינו [start, end] תקין: אורך שונה משניים, ערך אפס או שלילי, או start גדול מ-end

range_out_of_bounds בקריאת קובץ

start מעבר לסוף הקובץ. range.total_lines בקריאה רגילה יגיד כמה שורות יש בפועל

query_too_long בחיפוש פתקים

שאילתה ארוכה משם פתק אפשרי לא תתפוס דבר. קצרו אותה

sync_in_progress

רענון רץ ברגע זה — נסו שוב לפי retry_after

אין שום לוג מהשרת, גם כשהכול תקין

מאז #3393 ‏``mcp_server/app.py`` מגדיר לוגים בייבוא (‏``LOG_LEVEL``, ברירת מחדל INFO), ובעליית השירות אמורה להופיע שורת mcp dispatch capacity. אם גם היא חסרה — הרמה שבתוקף גבוהה מ-INFO, או שהתהליך לא הגיע ל-lifespan של ה-ASGI והכשל יופיע לפניה. היעדר שורה של כלי אינו סימן שהכלי לא נקרא: קריאות אינן רושמות שורה לכל קריאה — רק כתיבות (זמן ההמתנה בתור וזמן הריצה) — ומדידת השימוש עוברת ב-PostHog וגם סירוב אינו רושם שורה, וזו החלטה ולא פער (#3432, SUGG-022): תשובת {"ok": false, "error": ...} היא תשובה תקינה של הפרוטוקול — הקורא ביקש משהו שאין, או שאסור — ולא תקרית של השרת; ‏78 מסלולי סירוב בשכבת המטפלים אינם רושמים דבר, בכל הקבצים באותה מידה. השורה היחידה שנרשמת היא על תקלת תצורה שרק המפעיל יכול לתקן (repo_not_configured), וזה הקו: לוג למה שהמפעיל צריך לדעת, תשובה למה שהקורא צריך לדעת. ואין היום מקום שסופר סירובים לפי סוג: PostHog מודד כל קריאה שהגיעה לגוף כלי, אבל סירוב מגוף כלי נרשם שם כהצלחה (is_error מסומן רק על חריגה), וסירוב מכסה (rate_limited) אינו מגיע למדידה כלל — הוא מוכרע לפני התפר שהיא עוטפת. הפער מתועד באישו #3442; ראו גם ”מה נרשם בלוג“ ב-גבולות הבקשה — גודל הגוף והקצב

repo_or_ref_not_found

ה-mirror עוד לא שוכפל מקומית (המתינו למעבר autosync) או שאין דיסק/REPO_MIRROR_PATH

repo_not_mirrored בקריאת קובץ או עמוד תיעוד

אותו מצב, מהצד של get_file: למארח אין עותק של הריפו. זה אינו שם קובץ שגוי — אל תנסו נתיב אחר; בדקו את המראה (REPO_MIRROR_PATH, autosync). בזמן sync התשובה היא sync_in_progress

path_outside_root ב-codekeeper_docs_get_section

הנתיב פותר אל מחוץ לשורש התיעוד של הריפו (root בתשובה). נתיב מלא נלקח כמות שהוא; slug בלי / מעוגן לשורש

הסוכן שמר קובץ ולא הגיעה התראה

לפי הסדר: ‏(1) יש מנוי Push פעיל בדפדפן? נרשמים ב-/settings, ובלי מנוי האירועים נפסלים מיד. ‏(2) MCP_PUSH_NOTIFICATIONS_ENABLED כבוי על שירות ה-MCP? ‏(3) האם הוובאפ בכלל רץ — הוא ששולח, לא ה-MCP. ‏(4) עברה פחות מדקה? יש עיכוב של סבב. ‏(5) אם כל אלה תקינים, ייתכן שהמסירה נחתכת בתקרת הזמן — ראו תקרת זמן למסירה מקומית. לאבחון: /settings/push-debug

missing_items ב-codekeeper_read_batch

items היא רשימה ריקה. אין מה לקרוא, ולכן זה סירוב של הקריאה כולה ולא באץ« ריק שהצליח

too_many_items ב-codekeeper_read_batch

יותר פריטים ממה שבאץ« אחד נושא. max בתשובה הוא התקרה שבתוקף — MAX_BATCH_ITEMS, או מכסת הקצב לדקה כשהיא נמוכה ממנו. הבאץ« נדחה ולא נחתך; פצלו אותו לכמה קריאות

item_too_large בפריט של באץ«

הפריט לבדו גדול מכל מה שתשובה יכולה להכיל. read_with הוא הכלי הבודד שיקרא אותו, ופריט קובץ יכול גם לצמצם את עצמו ב-lines

invalid_item בפריט של באץ«

הפריט אינו תואם לסכימה, ו-problems אומר איפה ומה, בלי להדהד את הערך. השכיח: ref בפריט (פריטים אינם מקבלים ref), outline בפריט קובץ, או lines שאינו רשימה של מספרים שלמים. רק הפריט הזה נכשל. רשימה באורך שגוי, כמו [5], דווקא עוברת את הסכימה, וחוזרת מהכלי כ-invalid_line_range — בדיוק כמו בכלי הבודד

פריטים ב-unread של באץ«

הם לא נקראו, וכל מה שלפניהם שלם. unread_reason אומר למה: byte_budget — התשובה התמלאה, או timeout — עברו DEADLINE_SECONDS. בשני המקרים שולחים שוב את מה שב-unread, בקריאה חדשה

rate_limited על באץ«, כשנראה שנשארה מכסה

באץ« נשקל כמספר הפריטים שלו, והמכסה נגבית כולה או לא בכלל: באץ« של 20 פריטים כשנשארו 15 יחידות נדחה כולו, ו-retry_after_seconds הוא עד שכל העשרים נכנסים. באץ« קטן יותר עובר מיד

ראו גם