:summary: איך שרת ה-MCP שומר ומעדכן קבצים ופתקים במסד: התיאור וגילו, האם גרסה שנקראה לפי מזהה היא האחרונה, טביעת האצבע של התוכן ומניעת עריכה על גרסה ישנה, כמה עריכות בגרסה אחת, קריאת פתק עקבית, היסטוריית פתקים, שרשורי תגובות וההתראה כשסוכן שומר קובץ. שרת ה-MCP — קבצים ופתקים במסד: גרסאות, עקביות ומחזור חיים ========================================================= העמוד הזה הוא חלק מתיעוד שרת ה-MCP. התשתית, טבלת הכלים וטבלת הקבועים — ב-:doc:`mcp-server`. .. _mcp-update-description: עדכון תיאור בלי גרסה חדשה -------------------------- ``codekeeper_update_file_description`` מחליף את ה-``description`` של קובץ קיים, ולא נוגע בשום דבר אחר. הוא נועד למקרה שבו התיאור השמור התיישן ביחס לתוכן — הקובץ עצמו עודכן, והשורה שמתארת אותו בחיפוש וברשימות נשארה מלפני כמה עריכות. עד שהוא נוסף לא הייתה לכך דרך דרך ה-MCP: ``codekeeper_save_file`` מקבל ``description`` אבל מסרב לקובץ שכבר קיים, ו-``codekeeper_edit_file`` ו-``codekeeper_append_file`` **מעתיקים** את התיאור הקיים לגרסה החדשה ואין להם פרמטר לשנות אותו. הדרך היחידה לרענן תיאור הייתה מהדפדפן. **זהו אותו מסלול בדיוק** של "עדכון תיאור מהיר" שכבר קיים בעמוד הקובץ בוובאפ: שני הערוצים קוראים ל-``update_file_metadata_in`` ב-``database/repository.py``, ואין שני מימושים של אותה כתיבה. .. important:: **לא נוצרת גרסה, ולכן הקריאה עצמה לא שומרת עותק של התיאור הקודם.** השינוי הוא כתיבה אחת על מסמך הגרסה האחרונה. שלוש נגזרות שחשוב להכיר: - ``codekeeper_list_versions`` **לא** יציג את השינוי, והקריאה לא משאירה ממה לשחזר את הערך הקודם. התשובה של הכלי מחזירה אותו (``previous_description``). גרסה קודמת נושאת את אותו טקסט רק אם התיאור הגיע לגרסה האחרונה בהעתקה ממנה; תיאור שנקבע על הגרסה האחרונה עצמה קיים, אחרי הקריאה, רק בתשובה. - **רק הגרסה האחרונה מתעדכנת.** גרסאות קודמות נשארות עם התיאור שהיה בהן, וקריאה מפורשת של גרסה ישנה (``codekeeper_get_file`` עם ``version``) תחזיר אותו. זה עקבי עם מה שמוצג: גם החיפוש וגם רשימות הקבצים מקבצות לגרסה האחרונה לכל שם קובץ וקוראות את ``description`` ממנה. - התוכן ומספר הגרסה אינם זזים. מספר הגרסה מוחזר בתשובה במפורש כדי לומר זאת. בניגוד לכלי הכתיבה שיוצרים גרסה, הכלי הזה **אינו שולח התראת Web Push**: ההתראה אומרת שסוכן שמר קובץ, וכאן לא נשמר קובץ. ראו :doc:`/deployment/workers`. תיאור ארוך מ-``FILE_DESCRIPTION_MAX_CHARS`` תווים (``file_description.py``) **נדחה ואינו נחתך**, עם קוד ``description_too_long`` ושני מספרים, שניהם בתווים: ``max_chars`` — התקרה — ו-``actual_chars`` — האורך שנשלח, אחרי ``strip``; ולצידם ``message`` שאומר את היחידה במילים, ושום דבר לא נכתב. **עד #3489 הסירוב נשא רק ``max``**, בלי יחידה ובלי האורך שנשלח, וסוכן עם 413 תווים עבריים — 625 בתים — לא יכול היה לדעת ממנו אם הטקסט המקוצר שלו נכנס. ``max`` יצא ולא נשאר לצד השניים, כדי שלא יהיו בתשובה שני שדות שאומרים את אותו דבר; אלה אותם שמות של ``path_too_long`` ו-``section_too_long``. תיאור הפרמטר ``description`` נפתח בתקרה, ונבנה ממנה ברישום. הראוט בוובאפ דווקא חותך, וההבדל מכוון: שם אדם רואה את הטקסט בתיבה לפני השליחה ואחריה, וכאן סוכן אינו רואה את התוצאה — חיתוך שקט הוא טקסט שאבד בלי שיידע. מחרוזת ריקה היא בקשה תקפה, ומשמעותה ניקוי התיאור. **החריג: הטקסט שכבר שמור עובר בכל אורך.** קובץ יכול לשאת תיאור ארוך מהתקרה — הוא נשמר לפני שהתקרה נאכפה, או בבוט ובטפסי הוובאפ שיוצרים גרסה, שעוד אינם אוכפים אותה — ושליחה חוזרת של אותו טקסט אינה תיאור חדש אלא אישור שהוא עדיין מתאים: היא עונה ``unchanged: true`` ומאפסת את החותמת (הפסקה הבאה). "אותו טקסט" נבדק מול הגרסה האחרונה, **באותה כתיבה** ולא בקריאה לפניה: כל שדה שנכתב עטוף בתנאי על התיאור של המסמך שמתעדכן, וכשהוא אחר — שום שדה אינו זז (``update_file_metadata_in``). טקסט שזהה לגרסה **ישנה** יותר, כשהאחרונה כבר נושאת אחר, אינו "אותו טקסט", ונדחה. **הקריאה מסמנת את התיאור כנבדק.** היא מאפסת את ``description_age_versions`` (ראו :ref:`mcp-description-age`) גם כששלחתם בדיוק את הטקסט שכבר כתוב שם — ואז התשובה נושאת ``unchanged: true``, כדי שיהיה גלוי ששום דבר לא השתנה מלבד החותמת. זה הפוך מהכלל של עריכה, וזו הכרעה: עריכה שמעתיקה תיאור אינה אומרת דבר על התוכן, וקריאה לכלי הזה אומרת שמישהו השווה ביניהם. ניקוי התיאור **מסיר** את החותמת במקום לאפס אותה — לקובץ בלי תיאור אין גיל. .. _mcp-description-saved: תיאור ארוך בשמירה — ``description_saved`` ------------------------------------------ ``codekeeper_save_file`` מקבל ``description`` לקובץ החדש, ובאותה תקרה — ``FILE_DESCRIPTION_MAX_CHARS`` תווים. **תיאור ארוך ממנה אינו נשמר, והקובץ כן**: הקובץ נכתב בלי תיאור, והתשובה — ``ok: true``, כי הכתיבה קרתה — נושאת ``description_saved: false``, ``max_chars``, ``actual_chars``, ``message`` שאומר "תווים, לא בתים", ו-``hint`` שמפנה ל-``codekeeper_update_file_description``. **למה הקובץ נשמר בכל מקרה** — אותו נימוק של ``content_changed`` (:ref:`mcp-content-sha256`): ``ok: false`` היה שולח את הסוכן לשלוח שוב את כל התוכן, עד ``MAX_CODE_SIZE`` תווים, בגלל מטא-דאטה — ולהתנגש ב-``file_exists`` על הקובץ שכבר נשמר בניסיון הראשון. הקובץ נשמר, וכלי שמעדכן תיאור בלבד משלים את החסר. .. list-table:: :header-rows: 1 :widths: 22 78 * - ``description_saved`` - מה זה אומר * - ``true`` - התיאור נשמר. נגזר ממה ש\ **נקרא חזרה** מהאחסון אחרי הכתיבה (``file.description``), ולא מהבקשה. * - ``false`` - התיאור ארוך מהתקרה ולא נשמר; הקובץ נשמר בלעדיו. לצידו ``max_chars``, ``actual_chars``, ``message`` ו-``hint``. **בלי** השדות האלה — מה שנקרא חזרה אינו מה שנשלח (למשל עדכון תיאור מקביל שנחת בין הכתיבה לקריאה), ו-``file.description`` הוא מה שנשמר. * - ``null`` - הקריאה החוזרת נכשלה (``content_changed: null``), ולכן אין על מה לבסס ``true``. * - השדה נעדר - לא נשלח תיאור (או רק רווחים, ש-``strip`` מרוקן), או שהשמירה עצמה נכשלה — ``ok: false`` כבר אומר ששום דבר לא נשמר. **ואין כאן "זהה לקודם".** הכלי יוצר קובץ חדש בלבד (שם תפוס נדחה ב-``file_exists``), ולכן אין תיאור קודם להשוות אליו. התקרה חלה רק על תיאור **שנמסר**: כלי העריכה — ``codekeeper_edit_file``, ``codekeeper_multi_edit_file`` ו-``codekeeper_append_file`` — מעתיקים את התיאור הקיים לגרסה החדשה בכל אורך, וקובץ עם תיאור ישן וארוך נשאר ניתן לעריכה. .. _mcp-description-age: גיל התיאור — ``description_age_versions`` ------------------------------------------ תיאור מתיישן בשקט. כלי העריכה — ``codekeeper_edit_file``, ``codekeeper_multi_edit_file`` ו-``codekeeper_append_file`` — מעבירים את התיאור הקיים לגרסה החדשה, וזו ברירת מחדל נכונה — עריכת שורת קוד אינה מבטלת את מה שכתוב על הקובץ. מה שהיה חסר הוא **הסימן**: תיאור שנכתב בגרסה 1 נראה בגרסה 11 בדיוק כמו תיאור שנכתב אתמול, ואי אפשר היה לדעת שכדאי לבדוק אותו בלי לקרוא את כל הקובץ. השדה עונה על זה: **כמה גרסאות הקובץ זז מאז שהתיאור נקבע.** הוא מוחזר על ידי ``codekeeper_list_files``, ``codekeeper_search_code`` ו-``codekeeper_get_file``, ולצידו כבר יושב ``version`` — 3 בלי לדעת שהקובץ בגרסה 4 אינו אומר דבר. .. important:: **זה רמז לבדוק, לא קביעה שהתיאור שגוי.** עשר עריכות קטנות אינן הופכות תיאור לשגוי, ועריכה אחת גדולה כן יכולה. מה שהמספר אומר הוא "התיאור הזה לא נגע בתוכן כבר כמה זמן", ולא "התיאור הזה לא נכון". ו-``null`` אינו אפס. אפס הוא **טענה** — התיאור נקבע לגרסה הזו — ו-``null`` אומר שאיננו יודעים. שדה שנעדר לגמרי אומר משהו שלישי: אין כאן מה לבדוק. .. list-table:: :header-rows: 1 :widths: 25 75 * - מה חוזר - מתי * - מספר - הפרש בין מספר הגרסה לגרסה שבה התיאור נקבע. * - ``null`` - לקובץ יש תיאור, ואין חותמת שאפשר למדוד ממנה — נשמר לפני שהשדה קיים, או שהמיגרציה לא הצליחה לשחזר את שרשרת הגרסאות שלו. * - השדה נעדר - לקובץ אין תיאור, או שהוא כלל אינו ממוספר בגרסאות — קובץ גדול (``large_files``) נשמר בדריסה ואין לו ``version``. במקרים האלה ``null`` היה שולח לבדוק משהו שאין בו מה לבדוק. מתי החותמת זזה ~~~~~~~~~~~~~~~ החותמת עצמה (``description_set_at_version``) מאוחסנת על מסמך הגרסה, ו**הגיל מחושב בזמן קריאה** ואינו נשמר: ערך שמור היה חייב להתעדכן בכל שמירה, והשמירה שתשכח אותו הייתה מייצרת בדיוק את השקר שהשדה בא לחשוף. - **גרסה חדשה עם תיאור ששונה מהקודם** ← החותמת היא מספר הגרסה החדשה. - **גרסה חדשה עם אותו תיאור** ← החותמת עוברת כמות שהיא מהגרסה הקודמת. זה מה שגורם לגיל לספור עריכות במקום להתאפס בכל אחת מהן. - **עדכון תיאור בלי גרסה** (``codekeeper_update_file_description`` והעדכון המהיר בעמוד הקובץ) ← החותמת היא הגרסה הנוכחית, גם כשהטקסט זהה. - **גרסה קודמת בלי חותמת** ← אין חותמת גם בחדשה. אי-ידיעה נשארת אי-ידיעה; "השלמה" בגרסה החדשה הייתה הופכת אותה לטענה הגרועה ביותר האפשרית, שהתיאור נכתב עכשיו. **הכלל יושב במקום אחד**, ``file_description.py`` שבשורש, וכל מסלול שכותב גרסה מריץ אותו — מסלול השמירה של הבוט ושל ה-MCP (``save_code_snippet``) והראוטים בוובאפ שכותבים ישירות לאוסף. החותמת **נגזרת** מהשוואה בין התיאור שנכתב לזה של הגרסה הקודמת ואינה מועתקת ביד, כי העתקה ידנית הייתה נשמטת במסלול הראשון שמישהו יוסיף — והשדה היה מדווח "עדכני" בדיוק על המקרה שהוא נועד לחשוף. .. note:: הגיל הוא **הפרש של מספרי גרסה**, לא מניין עריכות שקרו. מספור הגרסאות רץ על כל המסמכים של אותו שם קובץ כולל אלה שבסל המיחזור, ולכן גרסאות שנמחקו משאירות חורים ברצף והמספר יוצא גדול במקצת. לשימוש שלו — סדר גודל — זה לא משנה. קבצים שנשמרו לפני שהשדה קיים ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ הם מחזירים ``null`` עד שמריצים מיגרציה חד-פעמית שמשחזרת את החותמת מההיסטוריה:: python scripts/migrate_description_set_at_version.py --dry-run python scripts/migrate_description_set_at_version.py היא הולכת אחורה בשרשרת הגרסאות כל עוד התיאור זהה, וקובעת את החותמת לגרסה המוקדמת ביותר ברצף. כששרשרת הגרסאות **קטועה** — חסר מספר גרסה באמצע הדרך אחורה, מה שקורה כשגרסאות עברו לסל — היא משאירה את הקובץ בלי חותמת, כי התיאור בגרסה החסרה הוא בדיוק מה שהיה מכריע. הדוח מפריד בין "קיבלו חותמת", "הושלמו", "כבר היו שלמים", "חותמת שונה מהשרשרת", "נשארו null" ו"בלי תיאור" — כל אחד מהם מצב אחר, ואיחוד שלהם היה הופך את הדוח לבלתי קריא. היא **אינה דורסת חותמת קיימת** לעולם. אבל "לא לדרוס" אינו "לא לגעת", והשניים נבדלים בדיוק במקרה שבו הרצה חוזרת נחוצה: הכתיבה אינה אטומית בין מסמכים, ולכן הרצה שנקטעה משאירה חלק מהשרשרת כתוב וחלק לא. - **החותמת הקיימת שווה למה שהשרשרת אומרת** ← המסמכים החסרים מושלמים. בטוח בהגדרה, כי הערך שנכתב זהה לזה שכבר שם. - **החותמת הקיימת שונה** ← לא נוגעים. ``codekeeper_update_file_description`` מזיז את החותמת בלי ליצור גרסה, ומאותו רגע שרשרת הגרסאות כבר אינה מתארת אותה; חישוב מחדש היה מחזיר את הגיל אחורה ומוחק עדכון אמיתי. .. _mcp-is-latest: האם זו הגרסה האחרונה — ``is_latest`` ------------------------------------- קריאה לפי ``file_id`` ב-``codekeeper_get_file`` מחזירה גרסה אחת מסוימת, גם כשכבר נשמרה חדשה ממנה. זו יכולת לגיטימית — קריאה בכמה שלבים נועלת כך גרסה אחת (:ref:`mcp-file-sections`) — אבל עד השדה הזה שום דבר בתשובה לא אמר שהגרסה כבר לא האחרונה, וסוכן שהחזיק איידי מסשן קודם עבד על תוכן ישן בלי לדעת. לכן מודיעים ולא חוסמים: ``file.is_latest`` אומר אם הגרסה שנקראה היא זו שקריאה לפי השם הייתה מחזירה **באותו רגע**. ``file_id`` הוא מזהה של **מסמך גרסה**, לא של קובץ (:ref:`mcp-downloads`), ולכן השאלה היא על זהות המסמך ולא חשבון על מספרי גרסאות. .. list-table:: :header-rows: 1 :widths: 22 78 * - ``is_latest`` - מתי * - ``true`` - זו הגרסה שקריאה לפי השם מחזירה עכשיו. * - ``false`` - קריאה לפי השם מחזירה גרסה פעילה אחרת. את התוכן העדכני קוראים לפי ``file_name``. * - ``null`` - אי אפשר לקבוע: השאילתה לפי השם נכשלה, הקובץ עבר לסל בין שתי הקריאות, או **תיקו** — שני מסמכים פעילים חולקים את המספר העליון והגרסה שנקראה היא אחד מהם (למטה). הקריאה עצמה מצליחה כרגיל — המסמך כבר נשלף. לא ``false``, שהיה שולח את הסוכן לקרוא שוב לפי שם בלי סיבה, ולא נעדר, שנראה כמו גרסה בסל. * - השדה נעדר - הגרסה שנקראה בסל המיחזור (``is_active: false`` — וזה כבר בתשובה). ``false`` היה מטעה: גם לקובץ שבסל יש גרסה אחרונה. במקרה הזה גם אין שאילתה נוספת. **בכל מצבי הקריאה.** מלא, ``lines``, ``query``, ``toc`` ו-``section``, וגם בסירובים שנושאים ``file`` — למשל ``answer_too_large`` על קריאה מלאה, שנושא את ``file`` בלי התוכן. השדה נוסף פעם אחת ב-``ProductionBackend.get_file``, על הפלט של ``_full``, ומשם עובר לכל הצורות. **רק בקריאה לפי** ``file_id``\ **.** קריאה לפי ``file_name``, עם ``version`` או בלעדיו, לא השתנתה ואין בה את השדה: מי שמעביר ``version`` ביקש גרסה מסוימת ויודע את זה, ומי שמעביר שם בלבד קיבל את האחרונה. השדה מכוון לאיידי שנגרר מסשן קודם. **השוואה לפי** ``_id`` **ולא לפי מספר הגרסה.** ההגדרה היא ``_is_latest_field`` ב-``mcp_server/backend.py``. היא שואלת את ``Repository._fetch_latest_version_ids`` (דרך ``get_latest_version_ids_fresh``) מה בראש הסדר של הקריאה לפי השם, ומשווה את ה-``_id`` של הראשון ל-``_id`` של מה שנקרא. מספר גרסה אינו מזהה: שני מסמכים פעילים יכולים לחלוק מספר, והשוואת מספרים הייתה נותנת ``true`` לשניהם. **תיקו על המספר העליון —** ``null``\ **.** ``Repository._fetch_latest_version``, הקריאה לפי שם, ממיינת לפי ``version`` בלבד (האזהרה בראש ``file_versions.py``), ולכן בשני מסמכים פעילים עם המספר העליון לא מוגדר איזה מהם היא מחזירה — ושתי שאילתות נפרדות יכולות לבחור אחרת. ``true`` לאחד מהם היה ניחוש. לכן השאילתה מחזירה **שני** מסמכים: כשהשני חולק את המספר של הראשון, והגרסה שנקראה נושאת אותו מספר, התשובה היא ``null``. גרסה ישנה מהתיקו היא ``false`` בוודאות. שובר שוויון בשאילתה עצמה (``LATEST_VERSION_SORT_BY``, כמו ברשימת הקבצים בוובאפ) היה נותן תשובה מוגדרת, אבל האינדקס ``idx_snippets_latest_version`` אינו כולל ``_id``, וכל קריאה לפי שם הייתה שולפת את כל הגרסאות של הקובץ כדי למיין אותן — שינוי נפרד, שנוגע גם במה שהקריאה לפי שם מחזירה. **אותו מסנן ואותו מיון של הקריאה לפי שם.** שתי השאילתות קוראות את ``Repository._latest_by_name_filter`` ואת ``_LATEST_BY_NAME_SORT`` ב-``database/repository.py``, ובאוסף בזיכרון (טסטים) את ``Repository._in_memory_latest_order``. כך הן עונות על אותה שאלה, גם כשאחת מהן תשתנה. **בלי התוכן, מה-primary ובלי קאש.** ההיטלה היא ``_id`` ו-``version`` בלבד: ``_latest_fresh`` מושכת את המסמך כולו, עד ``MAX_CODE_SIZE`` תווים, וכאן צריך רק מזהה. הקריאה היא מה-primary במפורש (``ReadPreference.PRIMARY``), כמו ``Repository.find_version_by_id``: ``readPreference`` בכתובת החיבור היה יכול להפנות את השאלה למשני שעוד לא ראה את השמירה האחרונה. ומהקאש — גרסה שנדרסה לפני רגע הייתה מסומנת ``true``. המסמך שנקרא לפי איידי עצמו (``get_file_by_id``) לא עבר ל-primary: הזהות שלו לא תלויה בשרת שענה. **"בסל" מוגדר במקום אחד.** ``ACTIVE_VERSION_FILTER`` ו-``is_active_version`` ב-``file_versions.py``: השאילתה של ``Repository._fetch_latest_version`` מסננת לפי המילון, ו-``_is_latest_field`` שואלת את הפונקציה על המסמך שכבר נקרא. כך אין פרדיקט שני של "בסל" שיכול להיפרד מהראשון. אותה פונקציה מסננת גם במסלול של ``_fetch_latest_version`` לאוסף בזיכרון, שרץ רק בטסטים — עד השינוי הזה הוא לא סינן כלום, ובטסטים קריאה לפי שם יכלה להחזיר גרסה שבסל. **המחיר: שאילתה אחת לפי משתמש ושם בכל קריאה לפי איידי של גרסה פעילה** — שני מזהים ושני מספרי גרסה, מאותו אינדקס של הקריאה לפי שם. ``GET /api/agent/download`` עובר באותה שליפה (:ref:`mcp-downloads`) ומשלם אותה גם הוא, בלי מסלול נפרד; השדה עצמו לא מגיע לכותרות של ההורדה. הבדיקות ב-``tests/test_mcp_is_latest.py``, דרך ``mcp.call_tool`` מעל ``ProductionBackend`` ו-``Repository`` אמיתיים. .. _mcp-content-sha256: טביעת אצבע לתוכן — ``content_sha256`` ו-``content_changed`` ------------------------------------------------------------ לסוכן לא הייתה דרך לוודא שמה שנשמר זהה למה ששלח. תשובות הכתיבה החזירו ``file_size``, ``lines_count`` וגרסה, וגודל זהה אינו תוכן זהה: טעות באורך זהה, או תו נסתר שהוחלף בתו אחר, לא נראים בו. כך התגלתה מחיקה של RLM רק בזכות הפרש של כמה בתים, וכך שורת סיום שנעלמה, בלי שהסוכן יכול לדעת באיזו שכבה זה קרה. השדות כאן הם רשת ביטחון כללית: שינוי שקט שיחזור, מכל שכבה ומכל סיבה, נראה מיד. **ההגדרה.** ``file.content_sha256`` הוא SHA-256 בהקס, על **בתי ה-UTF-8 של התוכן**, בדיוק כפי ש-``codekeeper_get_file`` מחזיר אותו, בלי שום נרמול: BOM, ‏CRLF וירידת שורה בסוף נכללים כמו שהם. בדיקה מקומית: .. code-block:: python hashlib.sha256(text.encode("utf-8")).hexdigest() או ``sha256sum`` על קובץ UTF-8 — כל עוד העורך לא הוסיף ירידת שורה בסוף ולא המיר סופי שורות בדרך, כי ה-hash הוא על הבתים המדויקים. **איפה הוא מופיע.** תמיד בתוך אובייקט ``file``, ובשם הזה בכל הכלים: - ``codekeeper_get_file`` — בכל מצב קריאה: מלא, ``lines``, ``query``, ``toc`` ו-``section``, וגם בסירובים של מצבי הסעיף שנושאים ``file``. זה תמיד ה-hash של **הגרסה כולה**, ולא של החלק שחזר. עם ``version=N`` או ``file_id`` — של אותה גרסה. - כלי הכתיבה של קבצים — ``codekeeper_save_file``, ``codekeeper_edit_file``, ``codekeeper_multi_edit_file`` ו-``codekeeper_append_file`` — ה-hash של מה ש\ **נקרא חזרה מהאחסון** אחרי הכתיבה, ולא של מה שנשלח. hash שמחושב מהקלט היה תמיד "תקין", וזה בדיוק הכשל שבאנו לתפוס. ולכן כשהקריאה החוזרת נכשלת אין hash בכלל, ו-``content_changed`` הוא ``null`` (בטבלה שבהמשך). - **הסירוב ``conflict`` של כלי העריכה** — ה-hash של הגרסה הנוכחית, זו שהעריכה הייתה נבנית עליה, ב-``file`` בלי התוכן. ראו :ref:`mcp-expected-content-sha256`. - **רשימות** — ``codekeeper_list_files``, ``codekeeper_search_code``, ``codekeeper_list_versions`` ו-``codekeeper_get_collection_items`` — **בלי hash**. רשימה מחזירה מטא-דאטה בלבד, ו-hash לכל פריט היה מחייב את התוכן של כולם. hash של גרסה אחת בזול: ``codekeeper_get_file`` עם ``version=N`` ו-``lines=[1, 1]`` — ה-hash הוא של הגרסה המלאה, ובשיחה עוברת שורה אחת. **אותה גרסה מחזירה תמיד אותו hash.** hash שמשתנה בלי שהגרסה משתנה פירושו שמשהו כתב את המסמך **במקום**, במקום לשמור גרסה חדשה. זה עוד נימוק לחשב ולא לשמור (ראו למטה): hash שמור היה נשאר כמו שהוא גם אחרי כתיבה כזו, ומסתיר אותה. **hash שונה בין שתי קריאות אינו בהכרח באג.** עורך הקבצים בוובאפ ממיר CRLF ל-LF בשליחת הטופס, ולכן גרסה שנשמרה שם יכולה להיות שונה בבתים מזו שלפניה בלי שאיש התכוון לשנות תוכן. ראו ``docs/quality/code-normalization.md``. תשובות הכתיבה: ``content_changed`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ אחרי הכתיבה המסמך נקרא חזרה, ומושווה למה שהכלי **התכוון** לשמור: בשמירה — מה שנשלח; בעריכה — הקובץ אחרי ההחלפה (ב-``codekeeper_multi_edit_file`` — אחרי כל הזוגות); בהוספה — התוכן הקודם, ירידת שורה מפרידה אם הוא לא נגמר בה, והתוספת. ההשוואה היא תו מול תו, בלי לדעת מה נרמול כלשהו עושה, ולכן היא תופסת גם שינוי שאף אחד עוד לא חשב עליו. שלושה מצבים, וכולם ``"ok": true``: הכתיבה קרתה, ו-``ok: false`` היה שולח סוכן לנסות שוב וליצור גרסה כפולה. .. list-table:: :header-rows: 1 :widths: 22 78 * - ``content_changed`` - מה זה אומר * - ``false`` - מה שנשמר זהה למה שהכלי התכוון לשמור, ו-``file.content_sha256`` הוא ה-hash שלו. * - ``true`` - מה שנשמר **שונה**. ``file.content_sha256`` הוא של מה שנשמר, ולצידו ``content_diff`` — סיכום קצר של ההבדל. בשרת נרשמת שורת ``WARNING`` אחת. * - ``null`` - הקריאה החוזרת נכשלה בשגיאת מסד, או לא מצאה את המסמך. ``file`` מינימלי — ``id``, ``file_name`` ו-``version`` — **בלי hash**, כי hash שהיה נבנה כאן היה חייב לבוא מהקלט. גם כאן נרשמת שורת ``WARNING``. חריגה שאינה של המסד היא באג, ועולה הלאה ולא הופכת ל-``null``. ``content_diff`` **אינו diff מלא**: הקידומת המשותפת והסיומת המשותפת נחתכות, ומה שנשאר ביניהן הוא **החלון** שהשתנה. החישוב סורק בבלוקים ואז מחפש בינארית בתוך הבלוק, כי הוא רץ בתור הכתיבה, שיש בו חוט אחד לכל השירות. .. code-block:: json {"line": 412, "offset_bytes": 30250, "intended_bytes": 3, "stored_bytes": 0, "intended_code_points": ["U+200F"], "stored_code_points": [], "code_points_truncated": false, "intended_total_bytes": 30314, "stored_total_bytes": 30311} ``line`` הוא מספר השורה של תחילת החלון, במונחים של ``lines=``, ו-``offset_bytes`` הוא המיקום שלו בבתי UTF-8. מכל צד חוזרים מספר הבתים בחלון, ועד ``CONTENT_DIFF_MAX_CODE_POINTS`` (‏32) code points בכתיב ``U+XXXX``; ``code_points_truncated`` אומר שהחלון ארוך מזה. ``intended_total_bytes`` ו-``stored_total_bytes`` הם הגודל של שני הטקסטים כולם. **מה ההשוואה רואה, ומה לא.** ההשוואה היא בין מה שהכלי **קיבל** לבין מה שנשמר, כלומר היא מכסה את הדרך מהכלי לאחסון. שינוי שקרה **לפני** הכלי — אצל הלקוח או בהמרת הארגומנטים של ה-SDK — נראה רק לסוכן, כשהוא משווה את ``content_sha256`` ל-hash שחישב בעצמו. וזה אפשרי רק כשהתוכן המלא בידיו: ב-``codekeeper_save_file`` תמיד, ובעריכה ובהוספה רק אם קרא את כל הקובץ. (המרת הארגומנטים של ה-SDK — ``pre_parse_json``, שמפענח כ-JSON מחרוזת שנשלחה לפרמטר שאינו מוצהר ``str`` בדיוק — אינה נוגעת בפרמטרי התוכן של ``codekeeper_save_file``, ``codekeeper_edit_file`` ו-``codekeeper_append_file``: הם מוצהרים ``str`` בדיוק, וה-SDK מדלג על כאלה בעצמו. ב-``codekeeper_multi_edit_file`` התוכן יושב בתוך ``edits``, שהוא רשימה, ולכן רשימה שנשלחה כטקסט JSON כן מפוענחת — אבל רק ברמה העליונה: המחרוזות שבתוך הזוגות מגיעות כמו שהן ב-JSON, ו-``"null"`` בתוך זוג נשאר המחרוזת ``"null"``. ומאז #3471 זה נכון לכל פרמטר שמקבל מחרוזת, גם ``str | None``: ``AdminAwareFastMCP`` מוציא אותם מהפענוח ברישום, ב-``_RawStringMetadata`` שב-``mcp_server/server.py``. נמדד על ``mcp 1.28.1``, ומקובע בטסטים ב-``tests/test_mcp_content_sha256.py`` וב-``tests/test_mcp_pre_parse_json.py``.) **הקריאה החוזרת היא לפי המזהה שה-insert החזיר, ולא לפי שם.** אם הבוט או הוובאפ שמרו גרסה משלהם בין הכתיבה לקריאה, "הגרסה האחרונה" הייתה התוכן **שלהם**, וההשוואה הייתה נדלקת על שינוי שהכלי לא עשה. היא גם אינה עוברת בקאש — ``get_latest_version`` עטופה ב-``@cached`` — ונקראת מה-primary במפורש, ורק עבור המשתמש שכתב (``find_version_by_id``). היא **מחליפה** את הקריאה לפי שם שהייתה שם, ואינה מוסיפה עוד סיבוב למסד. **השורה ביומן.** כש-``content_changed`` הוא ``true`` נרשמת שורת ``WARNING`` אחת, עם הכלי, ה-``_id``, הגרסה, מיקום החלון ומספרי הבתים — באותם שמות כמו ב-``content_diff`` — **בלי code points ובלי תוכן**. בלעדיה אף אחד חוץ מהסוכן לא יודע שהרשת תפסה משהו: PostHog רושם תשובה מגוף כלי כהצלחה, ו-``$mcp_response`` נחסם בשער הפרטיות (:ref:`mcp-analytics`). מאותה סיבה ``content_diff`` **לעולם לא** יוצא בחריגה: הודעת חריגה מגיעה ל-PostHog דרך ``$mcp_error_message``, והסיכום נושא קטעים מהתוכן. **תוכן שמור שאינו מחרוזת נופל בקול** — ``TypeError`` שנושא רק את שם הטיפוס, בכל מצב קריאה, ובעריכה ובהוספה לפני שנכתב משהו. אין מסלול שמירה שכותב דבר כזה, ולכן זה חוזה שנשבר; עד השינוי הזה ``lines`` ו-``query`` התייחסו לתוכן כזה כאל קובץ ריק, והעריכה וההוספה ענו ``empty_file``. למה מחשבים ולא שומרים ~~~~~~~~~~~~~~~~~~~~~~~ ה-hash מחושב מהתוכן בכל פעם שמחזירים אותו, ואינו נשמר במסמך: - **אותה שאלה בדיוק נענתה כבר** ב-:ref:`mcp-description-age`: ערך שמור חייב להתעדכן בכל שמירה, והשמירה שתשכח לעדכן אותו מייצרת בדיוק את השקר שהשדה בא לחשוף. כאן זה חמור יותר — ראוטים בוובאפ כותבים ישירות לאוסף, ו-hash שמור ושגוי היה אומר "התוכן תקין" על תוכן שהשתנה. - **המטרה:** הרשת באה לתפוס שינוי שקרה בדרך לאחסון. hash שמחושב בזמן הכתיבה מחושב **לפני** הדרך הזו, כלומר בדיוק לפני המקום שבו השינוי קורה. - **האינווריאנט שלמעלה** — אותה גרסה, אותו hash — נשמר מעצמו רק כשה-hash נגזר מהתוכן. ה-hash נשען על ``code`` של מסמך הגרסה, ולכן נכון לקבצים ב-``code_snippets``. אף כלי MCP אינו קורא או כותב קבצים גדולים (``large_files``). בפתקים הוא נשען על ``content`` של מסמך הפתק — הסעיף שלמטה. .. note:: שני hash-ים **שמורים** כבר יושבים על חלק ממסמכי ``code_snippets``, ואף אחד מהם אינו ``content_sha256``: ``contentHash`` של עובד האמבדינג, שכבר מוסר מתשובות ה-MCP (``_SEMANTIC_FIELDS`` ב-``mcp_server/backend.py``), ו-``content_hash`` של סנכרון הסימניות, שעוד יכול להופיע ב-``file`` — ונכתב רק ב-``check_file_sync`` (``database/bookmarks_manager.py``), ולכן יכול להיות ישן. ההשוואה היא תמיד מול ``content_sha256``. .. _mcp-note-content-sha256: פתקים: ``note.content_sha256`` ו-``content_changed`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ אותו חוזה, על ``note`` במקום ``file``. ``note.content_sha256`` הוא SHA-256 בהקס על בתי ה-UTF-8 של ``content`` כמו שהוא שמור, באותה פונקציה של הקבצים (``_content_sha256``). הוא מופיע ב-``codekeeper_get_note`` ובתשובה של כל כלי שכותב פתק — התשובה נבנית ב-``_note_write_answer`` ב-``mcp_server/backend.py`` — חוץ מיצירה שהקריאה החוזרת שלה נכשלה (למטה), ולא בכלי הרשימה והחיפוש, מאותה סיבה של הקבצים. ``content_changed`` מופיע כשהקריאה **כתבה גוף**: ביצירה וב-``codekeeper_note_str_replace`` תמיד, וב-``codekeeper_update_note`` רק כשנשלח ``content``. עדכון של צבע או של שורה מחזיר את ה-hash בלי ``content_changed``, כי אין גוף שהכלי התכוון לשמור. הערכים ומשמעותם הם אלה שבטבלה של הקבצים, וההכרעה היא אותה פונקציה, ``_content_verdict``, כולל שורת ה-``WARNING``. **מאיפה המסמך שמושווה — ולמה זה שונה בין עדכון ליצירה:** - **עדכון** (``codekeeper_update_note``, ``codekeeper_note_str_replace``) — המסמך ש-``find_one_and_update`` מחזיר עם ``ReturnDocument.AFTER``: המצב שהכתיבה **הזו** השאירה, באותה פעולה אטומית. קריאה נפרדת אחרי הכתיבה הייתה יכולה לתפוס כתיבה מקבילה של הוובאפ לאותו פתק, ולדווח עליה כאילו זה מה שהכלי שמר. לכן בעדכון אין ``null``: כשהמסנן לא תפס אף מסמך, התשובה היא ``conflict`` או ``not_found``. - **יצירה** — ``insert_one`` מחזיר רק את ה-``_id``, ולכן יש קריאה חוזרת לפי ``_id`` ו-``user_id``, מה-primary במפורש (``_primary_reader``), כמו בקבצים. כשהקריאה החוזרת נכשלת בשגיאת מסד, או לא מוצאת את הפתק, התשובה היא ``"ok": true`` עם ``note`` שנושא רק ``id``, ``content_changed: null`` ושורת ``WARNING`` — ``ok: false`` היה שולח את הסוכן ליצור פתק שני. ``tests/test_mcp_notes_handlers.py`` (בסעיף "תשובת הכתיבה של פתק") מריץ את שלושת המצבים, כתיבה מאוחרת של הוובאפ אחרי העדכון, ושכבה שמשנה את הגוף בדרך. .. _mcp-multi-edit: כמה עריכות בגרסה אחת — ``codekeeper_multi_edit_file`` ------------------------------------------------------- **למה.** עדכון אחד בקובץ — למשל שם שמופיע בכמה סעיפים, או מסמך מסירה שמתעדכן בכמה מקומות — נעשה עד עכשיו בכמה קריאות ל-``codekeeper_edit_file``. כל קריאה יצרה גרסה משלה, שלחה התראה משלה, והזקינה את התיאור בגרסה נוספת (:ref:`mcp-description-age`). וכשאחת מהן נכשלה באמצע, הקובץ נשאר מעודכן בחלקו: העריכות שלפניה כבר נשמרו, ומי שקורא אותו רואה מצב שאף אחד לא התכוון אליו. **מה הוא עושה.** ``edits`` היא רשימה של זוגות. כל זוג הוא ``old_string``, ``new_string`` ו-``replace_all`` — בדיוק הארגומנטים של ``codekeeper_edit_file`` בלי ``file_name``. הזוגות מוחלים לפי הסדר שנשלחו, כל זוג על התוצאה של הזוג שלפניו, והתוצאה נשמרת כגרסה אחת. זו הסמנטיקה של כמה קריאות עוקבות ל-``codekeeper_edit_file``, מקופלות לשמירה אחת: אותה פונקציית החלפה (``_apply_edit`` ב-``mcp_server/handlers.py``), ואותו מסלול שמירה (``_resave_edited``), שמעביר את השפה, התיאור והתגיות מהגרסה הקודמת. ולכן גם התראת Push אחת, והתיאור מזדקן בגרסה אחת. התשובה היא תשובת עריכה רגילה — ``file`` עם ``content_sha256``, ו-``content_changed`` (:ref:`mcp-content-sha256`) — ולצידה שני מונים: ``edits_applied``, כמה זוגות הוחלו, ו-``replacements``, כמה החלפות קרו בפועל. זוג עם ``replace_all`` שפגע בשבעה מופעים תורם שבע החלפות, בדיוק כמו ``replacements`` של ``codekeeper_edit_file``. **הכל או כלום.** זוג שנכשל עוצר את הקריאה, ושום דבר לא נכתב — לא גרסה ולא התראה, גם לא בשביל הזוגות שלפניו. זה אינו מנגנון נוסף אלא תוצאה של המבנה: כל התוצאה נבנית בזיכרון, ויש שמירה אחת בסוף. הסירוב נושא את הקוד של ``codekeeper_edit_file`` (``no_match``, ``ambiguous_match``, ``empty_old_string``, ``old_and_new_identical``), את ``index`` — המקום של הזוג ברשימה, מאפס — ו-``hint``. אחרי התיקון שולחים שוב את **כל** הבאץ', לא רק את הזוג. ושמירה שמסרבת אחרי שכל הזוגות הוחלו מוחזרת כמו שהיא, גם היא בלי גרסה. **שני מונים כשזוג לא מצא, או מצא יותר מדי.** ב-``no_match`` וב-``ambiguous_match`` חוזרים ``occurrences`` — כמה פעמים ``old_string`` נמצא בטקסט **אחרי** הזוגות שלפניו — ו-``occurrences_in_stored_version``, כמה פעמים הוא נמצא בקובץ כפי שנשמר. כשהשניים שונים, זוג קודם באותו באץ' שינה את מה שהזוג הזה מחפש, וה-``hint`` אומר את זה. זה המקרה שסוכן יוצר בלי לשים לב: הוא מקבל "לא נמצא" על מחרוזת שהוא ראה בקובץ בעיניים. חוזרים רק מספרים, לעולם לא טקסט. **מה נבדק לפני שקוראים את הקובץ.** רשימה ריקה (``missing_edits``), רשימה ארוכה מ-``MAX_EDIT_PAIRS`` (``too_many_edits``, עם ``max``), וזוג שאינו בצורה הנכונה (``invalid_edit``) — כל אלה נענים לפני שהקובץ נקרא מהמסד, וזוג פגום בסוף הרשימה נענה לפני שהזוג הראשון מוחל. רשימה ארוכה **נדחית ולא נחתכת**, כי באץ' שנחתך היה שומר חלק מהעריכות ומדווח הצלחה. ``invalid_edit`` נושא את ``index`` של הזוג הראשון שנכשל, ו-``problems``: איפה ומה, **בלי להדהד את הערך שנשלח** — כמו ``invalid_item`` של ``codekeeper_read_batch``, ובאותה פונקציה (``validation_problems``). הזוג קפדני: מפתח שאינו מוכר נדחה, ו-``replace_all`` חייב להיות בוליאני של JSON — המחרוזת ``"true"`` אינה ``true``. **גודל, לפני שהתוצאה נבנית.** כל זוג נבדק מול ``MAX_CODE_SIZE`` **לפני** שהתוצאה שלו נבנית, ולא רק בסוף. אורך התוצאה ידוע מראש — אורך הטקסט, ועוד מספר המופעים כפול ההפרש בין ``new_string`` ל-``old_string`` — ובלי הבדיקה הזו זוג שמחליף את ``"a"`` ב-``"aa"`` עם ``replace_all`` כופל את הטקסט בכל פעם שהוא חוזר, ובקשה קטנה בונה בזיכרון מחרוזת ענקית לפני שמישהו מודד אותה. הסירוב הוא ``code_too_large``, עם ``max`` ועם ``index`` של הזוג שחצה את התקרה. אותה בדיקה חלה גם על ``codekeeper_edit_file``: ``replace_all`` עם ``new_string`` ארוך נדחה לפני שהתוצאה נבנית. וגם על ``codekeeper_note_str_replace``, מול ``MAX_NOTE_CHARS`` ובקוד של הפתקים: תוצאה ארוכה מהתקרה — גם כזו של רווחים בלבד — נענית ``content_too_long`` עם ``max``, לפני ההקצאה (#3495). ``max_size`` של ``_apply_edit`` הוא חובה ובלי ברירת מחדל, כך שכלי שיחליף טקסט דרכו בעתיד חייב לנקוב בתקרה שלו: קריאה בלעדיה נכשלת, ושער ה-mypy ב-CI חוסם עליה (``call-arg``). והבקשה עצמה — כל הזוגות יחד — חסומה בתקרת גוף הבקשה (:ref:`mcp-request-limits`), שנגזרה מבקשת השמירה הגדולה ביותר: באץ' שגופו גדול ממנה נדחה ב-``body_too_large`` לפני שהכלי רץ, ומפצלים אותו לכמה קריאות. **מה הוא לא עושה.** - **פתקים** — ``codekeeper_note_str_replace`` נשאר עריכה אחת בכל קריאה. - **מיקום** — התשובה אינה אומרת באיזו שורה נחת כל זוג. ``codekeeper_get_file`` עם ``query`` מוצא אותו. - **כתיבה אטומית מול כותבים אחרים** — מספר הגרסה עדיין נבחר בקריאה ואחריה כתיבה, ואין אינדקס ייחודי על ``(user_id, file_name, version)``. בתוך שירות ה-MCP כתיבות עוברות אחת-אחת (תור הכתיבה, ``_WRITE_POOL`` ב-``mcp_server/server.py``), אבל כתיבה מהוובאפ או מהבוט יכולה לנחות באמצע. הסגירה היא הכיוון שמתואר ב-#3391. .. _mcp-expected-content-sha256: עריכה על מה שקראתם — ``expected_content_sha256`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ **למה.** סוכן קורא קובץ — לפעמים בכמה שלבים: מפה, סעיף, טווח — בונה עריכה, ושולח אותה. אם בינתיים הקובץ השתנה (סשן אחר, הוובאפ, הבוט), העריכה נבנית על בסיס שהסוכן לא ראה, ואין לכך שום סימן. ``old_string`` שעדיין נמצא אינו הוכחה ששאר הקובץ לא זז. **מה.** ``codekeeper_edit_file``, ``codekeeper_multi_edit_file`` ו-``codekeeper_append_file`` מקבלים ``expected_content_sha256``: ה-``file.content_sha256`` שהסוכן קרא (:ref:`mcp-content-sha256`). הוא מושווה ל-hash של הגרסה שהעריכה עומדת להיבנות עליה, **לפני** שמשהו מוחל, ואם הם שונים — ``conflict``, ושום דבר לא נכתב. בסירוב יש ``file``: המטא-דאטה של הגרסה הנוכחית בלי התוכן, עם ``version`` ו-``content_sha256``. מה עושים: קוראים שוב ב-``codekeeper_get_file``, בונים את העריכה על התוכן הנוכחי, ושולחים שוב. ה-hash חוזר בכל מצב קריאה של ``codekeeper_get_file``, גם ``toc`` ו-``section``, כך שסוכן שקרא בשלבים כבר מחזיק אותו — ו-``lines=[1, 1]`` מביא אותו בזול. ההשוואה אינה תלויה ברישיות. בלי הפרמטר אין בדיקה, בדיוק כמו קודם. **מה הוא תופס.** כל כתיבה שמשנה את התוכן, גם כזו שאינה מעלה גרסה: ``check_file_sync`` ב-``database/bookmarks_manager.py`` כותב ``code`` במקום (``update_one``), בלי גרסה חדשה. זו הסיבה שהשער הוא hash ולא ``version`` — המספר לא זז, התוכן כן. **מה הוא אינו סוגר.** בין כותבי ה-MCP הבדיקה והכתיבה אינן משתלבות: גוף הכלי רץ על העובד היחיד של תור הכתיבה, ולכן שתי עריכות MCP אינן רצות באותו זמן. אבל כתיבה מהוובאפ או מהבוט — תהליכים אחרים — עדיין יכולה לנחות בין הבדיקה לשמירה. השער מצמצם את החלון ואינו סוגר אותו; הסגירה היא אינדקס ייחודי ועדכון מותנה, הכיוון שמתואר ב-#3391. **ערך שאינו hash.** ``expected_content_sha256`` שאינו 64 ספרות הקס נענה ב-``invalid_expected_content_sha256``, לפני כל קריאה מהמסד — ולא ב-``conflict``: שגיאת כתיב שהייתה נענית ``conflict`` הייתה שולחת את הסוכן לקרוא מחדש ולשלוח שוב את אותו ערך שגוי. .. _mcp-get-note: קריאת פתק בודד — ``codekeeper_get_note`` ----------------------------------------- עד שהכלי הזה נוסף לא הייתה דרך לקרוא פתק אחד לפי מזהה. ``codekeeper_search_notes`` מחזיר מזהה בלי תוכן, ``codekeeper_get_note_version`` מחזיר תוכן רק לגרסאות קודמות, וכלי הרשימה מחזירים את המשטח כולו — לוח של כמה עשרות פתקים חוזר בעשרות אלפי תווים כדי להגיע לפתק אחד, וזה יותר ממה שלקוח מציג. סוכן בלי shell לא יכול היה לקרוא את הפתק בכלל, ו- ``codekeeper_note_str_replace``, שדורש את הגוף הנוכחי המדויק, חייב משיכה של הלוח כולו לפני כל עריכה. **התקציב של השרת לא הגן על המקרה הזה:** עד #3460 ``OUTPUT_BYTE_BUDGET`` לא נאכף על רשימות פתקים כלל, לוח שלם עבר מתחתיו, ומה שחסם היה הלקוח. מאז ``codekeeper_list_notes`` ו-``codekeeper_list_board_notes`` מודדים את התשובה כפי שהיא נשלחת, ורשימה מלאה שאינה נכנסת היא ``answer_too_large`` עם הפניה ל-``include_content=false`` ואז לכלי הזה — ולא רשימה חתוכה, כי לכלי הרשימה אין עימוד, ופתק שנחתך מהם לא היה נגיש בשום דרך (ראו :ref:`mcp-answer-size`). גוף פתק אחד חסום ב-``MAX_NOTE_CHARS`` תווים, ועד שרשורי התגובות זה חסם את התשובה כולה מראש. **השרשורים שברו את זה** — הם תוכן שאינו תחת החסם הזה, ומספר השרשורים על פתק אינו מוגבל — ולכן מאז הכלי מצהיר על תקרת תשובה ועומד בה בעצמו (:ref:`mcp-answer-size`, :ref:`mcp-note-threads`). **מה חוזר.** ``note`` — הפתק באותה צורה שכלי הרשימה מחזירים (תוכן, כותרת, ``color`` ו-``color_id``, שורה, מועדים); ``version`` — מספר הגוף **הנוכחי** (``null`` לגוף ריק: ההיסטוריה אינה מצלמת גוף ריק, ולכן אין מספר שיחזיר אותו, ולא ממציאים אחד); ואיפה הפתק יושב, במוסכמה של פגיעת חיפוש: ``target`` ואיתו בדיוק הארגומנטים שכלי הרשימה המתאים דורש — ``file_name`` (ו-``file_id``), ``board_id``, או ``repo_name`` + ``repo_path``; ופתק בארכיון — ``target: "archive"`` לבדו, כי אין לארכיון כלי רשימה, והפתק נקרא לפי המזהה (:ref:`sticky-notes-archive`). פתק קובץ ישן שנושא ``file_id`` בלבד מקבל ``file_name`` באותה השלמה שהחיפוש עושה. פתק ריפו נושא גם ``orphaned: true`` כשהנתיב כבר אינו בעץ המשוקף, באותו כלל של ``codekeeper_list_repo_notes`` ומאותו קוד (``repo_path_orphaned`` ב-``mcp_server/backend.py``): הדגל נדלק רק על תשובה מפורשת מהמניפסט, ושאילתה שנכשלה אינה מסמנת קובץ חי כמיותם. **ואחרי הפתק — ``threads``**, שרשורי התגובות שעליו; מה בדיוק חוזר, ו-``part`` / ``thread_id`` — :ref:`mcp-note-threads`. **זו אותה קריאה-לפי-מזהה של** ``codekeeper_note_str_replace`` — ``get_note`` ב-``mcp_server/backend.py`` — ולא מסלול שני. הבעלות יושבת במסנן של השאילתה, ולכן פתק של משתמש אחר הוא ``not_found``, אותה תשובה כמו למזהה שאינו קיים. **הגוף חוזר בדיוק כפי שהוא מאוחסן, כמו בכל כלי הפתקים.** אף כלי אינו מפענח את הגוף, ולכן ``old_string`` שהועתק מ-``codekeeper_get_note`` או מכלי רשימה הוא אותו טקסט, "הגוף של גרסה N" (``codekeeper_get_note_version``) ו"הגוף הנוכחי" ניתנים להשוואה בית-בית, וגודל הגוף ברשימה (``content_bytes``, למטה) הוא בדיוק גודל מה שהכלי יחזיר. ``note.content_sha256`` הוא ה-hash של הגוף הזה (:ref:`mcp-note-content-sha256`). עד אוקטובר 2026 כלי הרשימה פענחו ישויות HTML בקריאה, והכלי הזה עקף זאת בשדה נפרד, ``stored_content``; כשהפענוח הוסר השדה נעשה זהה ל-``note.content``, ונמחק. ``test_the_read_edit_loop_keeps_entities_exactly_as_typed`` ב-``tests/test_mcp_notes_handlers.py`` מריץ את הלולאה כולה — קריאה, ``codekeeper_note_str_replace`` עם ``old_string`` שהועתק, וקריאה שוב — על פתק שיש בו ישויות, ובודק את המצב במסד. **הגוף והגרסה מתארים את אותו רגע, וזה נאכף ולא מונח.** הגרסה יושבת באוסף אחר (``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`` משווה את **התוכן** שנקרא בתוך הקריאה עצמה מול מה שבמסד (ראו :ref:`mcp-note-history`); ``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`` ו-``open_threads`` — כמה שרשורי תגובות פתוחים יש על הפתק (:ref:`mcp-note-threads`; ``null`` כשהספירה נכשלה — "לא ידוע", לא אפס). ``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`` — ראו :ref:`mcp-read-mode`. .. _mcp-note-history: היסטוריית פתקים ---------------- ``codekeeper_update_note`` דורס את **כל** תוכן הפתק במה שנשלח. עד היום לא היה ממה לשחזר: שורה אחת שגויה מוחקת פתק בן 100 שורות, וכלי שרץ בלי אישור היה מוחק אותו בשקט. לכן כל עדכון **שמשנה** את ה-``content`` שומר את הגוף הקודם באוסף ``sticky_note_versions`` — **לפני** הדריסה. עדכון עם תוכן זהה אינו מייצר גרסה: כפילות הייתה דוחפת החוצה גרסה שעוד אפשר לשחזר, בתמורה לעותק של מה שכבר יש. הקריאה היא read-modify-write, ולכן הסדר אינו הפיך: ``codekeeper_note_str_replace`` נוסף **אחרי** ההיסטוריה ולא לפניה, שאם לא כן היה עוד מסלול שדורס בלי רשת. ``codekeeper_note_str_replace`` נושא גם **שער אופטימי**: הדריסה מותנית בכך שהגוף במסד עדיין שווה למה שנקרא. שתי עריכות חופפות בלי השער היו מדווחות שתיהן הצלחה בזמן שהאחרונה מוחקת את עריכת הראשונה; עם השער המפסידה מקבלת ``conflict`` — קוראים את הפתק מחדש ב-``codekeeper_get_note`` ומנסים שוב (ראו :ref:`mcp-get-note`). .. list-table:: :header-rows: 1 :widths: 30 70 * - עובדה - פירוט * - אוסף נפרד - ולא מערך מוטבע במסמך הפתק: שלוש פונקציות הרשימה קוראות בלי פרויקציה, ומערך היסטוריה היה נגרר לכל רשימת פתקים — הפרה של 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-note-threads: שרשורי תגובות על פתקים — ``codekeeper_comment_note`` ------------------------------------------------------ על פתק אפשר לפתוח שרשורי תגובות: המשתמש מגיב מהוובאפ, וכל סוכן מגיב דרך ה-MCP — וסוכן יכול לענות אחרי סוכן אחר. השרשור מעוגן לפתק כולו ולא לשורה (שורה נערכת, ועיגון אליה נשבר), ומספר השרשורים על פתק אינו מוגבל. **אוסף נפרד, וכללים במקום אחד.** כל שרשור הוא מסמך באוסף ``note_threads``, והתגובות בתוכו. אוסף נפרד ולא שדה בפתק: אילו התגובות ישבו במסמך הפתק, כל תגובה של סוכן הייתה כתיבה לפתק — מתנגשת עם עריכה של המשתמש באותו רגע (``conflict`` של ``codekeeper_note_str_replace``), וממלאת את היסטוריית הגרסאות בצילומים שלא שינו את הגוף. **כל הכללים** — בניית המסמך, פעולות הכתיבה, התקרות, הקריאות, מפרט האינדקסים ואירוע ההתראה — יושבים במודול שורש טהור אחד, ``note_threads.py``, שגם הוובאפ מייבא. לאוסף שני כותבים, וכלל שכתוב פעמיים מתפצל; ``tests/test_note_threads_single_writer.py`` נופל על כתיבה לאוסף מכל מקום אחר. **מי כתב.** לכל תגובה ``author``: ``kind`` (``user`` או ``agent``), ``client`` ו-``working_on``. **סוג הסוכן מגיע מהחיבור ולא מפרמטר של הכלי**: שם הלקוח שהוצהר בחיבור, אותו שם שמופיע כ-``$mcp_client_name`` בלוח האנליטיקס — כך שהמודל אינו כותב אותו בקריאה לכלי. **אבל זו הצהרה של הלקוח, ולא זיהוי מאומת:** הלקוח מצהיר על ``clientInfo.name`` בעצמו ב-``initialize``, ולכן לקוח — או סוכן שמחזיק טוקן אישי ופותח חיבור משלו — יכול להצהיר על שם של סוכן אחר. **השרת רץ ``stateless_http``**, ולכן ב-``tools/call`` אין ``client_params`` (ה-``initialize`` נשלח בבקשה אחרת). השם מגיע מהטוקן ש-``PostHogMcpStatelessSessionMiddleware`` מטביע בכותרת ``Mcp-Session-Id`` ב-``initialize`` והלקוח שולח בחזרה בכל בקשה (``mcp_server/client_identity.py``). **התלות ב-PostHog אמיתית:** המידלוור מותקן רק כש-PostHog מוגדר. בלעדיו, או בלקוח שאינו שולח את הכותרת, התגובה נשמרת עם ``client: null``, מוצגת "סוכן", ונרשמת שורת WARNING. **על מה הסוכן עבד** מגיע מהסוכן, בפרמטר ``working_on``: חובה בכל קריאה לכלי — גם בסגירה בלי תגובה, ואז הוא נשמר ב-``resolved_by`` — שורה אחת עד ``MAX_WORKING_ON_CHARS`` תווים: המשימה שהסוכן באמצעה כשכתב את התגובה הזו, לא שם השיחה ולא סיכום הסשן. שם התצוגה של הכותב (``claude-code`` ← CC, ``Anthropic/ClaudeAI`` ← Claude, ``Gemini`` ← Gemini, ולקוח אחר — בשמו) הוא ``note_threads.author_label``. **הרשאה.** ``codekeeper_comment_note`` עובר באותו שער של ``codekeeper_get_note`` (``_readable_note``) **לפני כל כתיבה**: פתק של משתמש אחר, ופתק ריפו למי שאינו אדמין — ``not_found``, ושום דבר לא נכתב. השער נבדק על הפתק ואינו נגזר מקיום ה-``thread_id``; והכתיבה עצמה תחומה ל-``(user_id, note_id)``, כך ש-``thread_id`` של משתמש אחר, או של פתק אחר, הוא ``thread_not_found``. ``user_id`` מגיע תמיד מהטוקן. **אטומיות ותקרות.** כל שינוי של שרשור קיים הוא ``find_one_and_update`` אחד: התגובה נדחפת, ובאותה פעולה מתעדכנים ``last_author_kind``, ``last_at``, הסטטוס והמונים — אין מצב ביניים שבו התגובה נכתבה והסטטוס עוד לא. התקרות יושבות בפילטר (CAS), ולכן כתיבות מקבילות אינן עוברות אותן יחד: ``MAX_COMMENT_CHARS`` לתגובה, בתווים (ארוכה יותר — ``body_too_long``, לא נחתכת); ``MAX_COMMENTS_PER_THREAD`` לשרשור (``thread_full``, בלם ללולאת סוכנים); ו-``MAX_THREAD_BODY_BYTES`` על סך גופי התגובות בשרשור, בבתים כפי שהם נשלחים ב-JSON (``thread_too_long``). **התקרה השלישית אינה באפיון**, והיא שומרת על חוזה אחד: שרשור אחד תמיד נכנס בתשובה אחת. בלעדיה שרשור מלא בעברית שוקל יותר מ-``OUTPUT_BYTE_BUDGET``, והתגובות הראשונות שלו היו נעלמות מכל סוכן. אחרי שתי התקרות האחרונות ממשיכים בשרשור חדש על אותו פתק. גוף תגובה נשמר בדיוק כפי שנשלח, באותו כלל של גוף פתק. **תשובה בשרשור שנפתר פותחת אותו** כברירת מחדל, באותה כתיבה; ``reopen=false`` עונה ומשאיר אותו נפתר. סגירה שומרת מי סגר (``resolved_by``) ומתי; שרשור שכבר נפתר עונה ``already_resolved`` ושום דבר לא נכתב, גם לא הסיכום. **תשובת הכלי נקראת מהמסד** אחרי הכתיבה: מצב השרשור, והתגובה עם ``body_sha256`` של הגוף השמור (``null`` כשלא נקרא בחזרה). **קריאה — ``codekeeper_get_note``.** אחרי הפתק באים ``threads``: הפתוחים קודם, ובכל קבוצה הפעילות האחרונה קודם. לכל שרשור ``number`` — מקומו לפי סדר היצירה ("שרשור 2" בממשק). ``part="all"``, ברירת המחדל: שרשור פתוח עם ``NOTE_THREAD_DEFAULT_TAIL`` התגובות האחרונות שלו (``earlier_comments`` סופר את השאר), ושרשור שנפתר כשורה מקופלת — בלי ``comments``, עם ``resolved_by`` ו-``resolved_at``. ``part="threads"``: בלי גוף הפתק, כל השרשורים במלואם, כולל אלה שנפתרו; ועם ``thread_id`` — שרשור אחד. **השרשורים נקראים אחרי הקריאה העקבית של הפתק ומחוץ לה**: אין קשר של גרסה בין גוף הפתק לשרשור. **ובתוך התקציב:** לפני שתגובה כלשהי נקראת מהמסד, ``handlers._plan_threads`` מחליט מה יוצג — מהמונים השמורים (``comment_count``, ``body_bytes``) ומחסמים עליונים שנמדדים באותה פונקציית מדידה של השרת — ושרשור שלא ייכנס אינו נשלף. שרשור שאינו נכנס עם תגובותיו מוצג מקופל, עם המזהה שלו, והתשובה נושאת ``comments_truncated``; שרשורים שגם מקופלים אינם נכנסים יורדים מהרשימה (``threads_truncated``); ו-``threads_hint`` אומר איך לקרוא את מה שלא הוצג. כשל בקריאת השרשורים: ב-``part="all"`` הפתק חוזר עם ``threads: null`` ו-``threads_error``, וב-``part="threads"`` — ``threads_unavailable``. **רשימות.** ``open_threads`` בשורה הרזה נספר בשאילתה מקובצת **אחת** על כל הפתקים שברשימה — לא קריאה לכל פתק, וגם לא בתוך ``_lean_notes_pipeline``: ``note_id`` נשמר כמחרוזת מול ``_id`` שהוא ``ObjectId``, וכשל של הספירה משאיר את הרשימה עם ``null`` במקום להפיל אותה. **הפריימר** נושא שורת מצב שנייה, עם הפתקים שמחכה בהם תגובה לסוכן — ראו :ref:`agent-primer`. **התראה.** תגובה של סוכן שנכתבה רושמת אירוע ``note_comment`` ב-``push_events`` — מזהי הפתק, השרשור והתגובה, איפה הפתק יושב, והכותב — באותו מנגנון של :ref:`mcp-push-notifications` ובאותו דגל (``MCP_PUSH_NOTIFICATIONS_ENABLED``). סגירה בלי סיכום אינה תגובה, ותגובה של המשתמש אינה מתריעה לו. שולח הפוש בוובאפ קורא בזמן השליחה את שם הפתק ואת התגובה עצמה, ושולח התראה שהלחיצה עליה פותחת את השרשור — ואינה מאשרת תזכורת של הפתק. הפרטים, כולל חלון המעבר של Service Worker ישן: :doc:`/deployment/workers`. **מחיקה.** מחיקת פתק בוובאפ היא מחיקה קשה (``delete_one``); אין לפתקים סל מחזור, וגיבוי משחזר פתק עם ``_id`` חדש — ולכן שרשורים אינם חוזרים עם פתק משוחזר. ראוט המחיקה בוובאפ קורא ל-``note_threads.delete_note_threads`` **רק אחרי** שמחיקת הפתק עצמו הצליחה (``deleted_count == 1``). שרשור יתום עדיין אפשרי — כשמחיקת השרשורים נכשלת אחרי שהפתק נמחק, או כשסוכן מגיב בדיוק ברגע המחיקה — ואיש אינו רואה אותו: כל הקוראים מגיעים לשרשורים דרך פתק קיים. **אינדקסים.** ``(user_id, note_id)`` לקריאת השרשורים של פתק ולספירה, ו-``(user_id, status, last_author_kind)`` לשורת הפריימר — מוצהרים ב-``DatabaseManager._create_indexes`` (``database/manager.py``), המסלול ששני השירותים עוברים בעלייה, מהמפרט ``note_threads.note_threads_indexes``. .. _mcp-push-notifications: התראה כשסוכן שומר קובץ ----------------------- כל שמירת קובץ דרך ה-MCP — ``codekeeper_save_file`` וכלי העריכה ``codekeeper_edit_file``, ``codekeeper_multi_edit_file`` ו-``codekeeper_append_file`` — שולחת למשתמש התראת Web Push. זה נועד למקרה שבו סוכן כותב בזמן שאיש אינו מסתכל: בלי ההתראה, הכתיבה מתגלה רק כשנכנסים. **המקור נקבע במיקום, לא בשדה.** ה-hook יושב ב-``ProductionBackend.save_file``, ומחלקה זו מיובאת רק בשירות ה-MCP. כתיבה מהוובאפ או מהבוט אינה עוברת שם ולכן אינה יכולה לייצר התראה כזו, בלי שדה ``origin`` ובלי דגל מקור. כל כלי הכתיבה של קבצים מתנקזים לאותה מתודה, ולכן ה-hook אחד. ``codekeeper_update_file_description`` **אינו** שולח התראה, וזו החלטה ולא השמטה: הוא אינו עובר ב-``save_file`` כי הוא אינו שומר קובץ — לא נוצרת גרסה והתוכן אינו זז. התראה שאומרת "סוכן שמר קובץ" על שינוי שהמשתמש לא יזהה כשמירה היא רעש, ולא מידע. .. important:: **שירות ה-MCP אינו שולח את ההתראה.** הוא רושם שורה באוסף ``push_events``, ושולח הפוש שחי בתהליך הוובאפ אוסף אותה בסבב הבא. המשמעות המעשית: יש עיכוב של עד דקה, **ובפריסה שבה רק שירות ה-MCP רץ ההתראות לא יישלחו כלל** — הן יירשמו, ואינדקס TTL ינקה אותן אחרי שבוע. - **כיבוי:** ``MCP_PUSH_NOTIFICATIONS_ENABLED=false`` על שירות ה-MCP. הדגל נקרא בנקודת הרישום, ולכן כיבוי עוצר את הרישום עצמו ואינו מותיר תור שאיש אינו קורא. - **תנאי מוקדם:** למשתמש צריך להיות מנוי Push פעיל בדפדפן (נרשמים ב- ``/settings``). בלעדיו האירועים נפסלים מיד ואינם נצברים. - **כמה התראות:** אחת לכל שמירה. שתי שמירות רצופות לאותו קובץ מגיעות כהתראה אחת שמתעדכנת, כי ה-``tag`` מזהה את הקובץ ולא את הגרסה. ``codekeeper_multi_edit_file`` הוא שמירה אחת, ולכן התראה אחת לכל הבאץ'. התיעוד המלא של המנגנון — האוסף, האינדקסים, תקרת הניסיונות ומה קורה כשמסירה נכשלת — יושב ב-:doc:`/deployment/workers`.