שרת ה-MCP — קבצים ופתקים במסד: גרסאות, עקביות ומחזור חיים

העמוד הזה הוא חלק מתיעוד שרת ה-MCP. התשתית, טבלת הכלים וטבלת הקבועים — ב-שרת ה-MCP — חיבור Claude ל-CodeKeeper.

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

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.

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

תיאור ארוך בשמירה — 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 (טביעת אצבע לתוכן — content_sha256 ו-content_changed): ok: false היה שולח את הסוכן לשלוח שוב את כל התוכן, עד MAX_CODE_SIZE תווים, בגלל מטא-דאטה — ולהתנגש ב-file_exists על הקובץ שכבר נשמר בניסיון הראשון. הקובץ נשמר, וכלי שמעדכן תיאור בלבד משלים את החסר.

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

גיל התיאור — 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 אינו אומר דבר.

חשוב

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

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

האם זו הגרסה האחרונה — is_latest

קריאה לפי file_id ב-codekeeper_get_file מחזירה גרסה אחת מסוימת, גם כשכבר נשמרה חדשה ממנה. זו יכולת לגיטימית — קריאה בכמה שלבים נועלת כך גרסה אחת (קריאה לפי סעיף בקובץ Markdown שמור (toc ו-section)) — אבל עד השדה הזה שום דבר בתשובה לא אמר שהגרסה כבר לא האחרונה, וסוכן שהחזיק איידי מסשן קודם עבד על תוכן ישן בלי לדעת. לכן מודיעים ולא חוסמים: file.is_latest אומר אם הגרסה שנקראה היא זו שקריאה לפי השם הייתה מחזירה באותו רגע.

file_id הוא מזהה של מסמך גרסה, לא של קובץ (הורדת קובץ לדיסק בלי לעבור דרך המודל), ולכן השאלה היא על זהות המסמך ולא חשבון על מספרי גרסאות.

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

הבדיקות ב-tests/test_mcp_is_latest.py, דרך mcp.call_tool מעל ProductionBackend ו-Repository אמיתיים.

טביעת אצבע לתוכן — content_sha256 ו-content_changed

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

ההגדרה. file.content_sha256 הוא SHA-256 בהקס, על בתי ה-UTF-8 של התוכן, בדיוק כפי ש-codekeeper_get_file מחזיר אותו, בלי שום נרמול: BOM, ‏CRLF וירידת שורה בסוף נכללים כמו שהם. בדיקה מקומית:

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

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

{"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 נחסם בשער הפרטיות (מדידת שימוש (PostHog MCP Analytics)). מאותה סיבה content_diff לעולם לא יוצא בחריגה: הודעת חריגה מגיעה ל-PostHog דרך $mcp_error_message, והסיכום נושא קטעים מהתוכן.

תוכן שמור שאינו מחרוזת נופל בקול — TypeError שנושא רק את שם הטיפוס, בכל מצב קריאה, ובעריכה ובהוספה לפני שנכתב משהו. אין מסלול שמירה שכותב דבר כזה, ולכן זה חוזה שנשבר; עד השינוי הזה lines ו-query התייחסו לתוכן כזה כאל קובץ ריק, והעריכה וההוספה ענו empty_file.

למה מחשבים ולא שומרים

ה-hash מחושב מהתוכן בכל פעם שמחזירים אותו, ואינו נשמר במסמך:

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

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

  • האינווריאנט שלמעלה — אותה גרסה, אותו hash — נשמר מעצמו רק כשה-hash נגזר מהתוכן.

ה-hash נשען על code של מסמך הגרסה, ולכן נכון לקבצים ב-code_snippets. אף כלי MCP אינו קורא או כותב קבצים גדולים (large_files). בפתקים הוא נשען על content של מסמך הפתק — הסעיף שלמטה.

הערה

שני 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.

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

כמה עריכות בגרסה אחת — codekeeper_multi_edit_file

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

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

מה הוא לא עושה.

  • פתקים — codekeeper_note_str_replace נשאר עריכה אחת בכל קריאה.

  • מיקום — התשובה אינה אומרת באיזו שורה נחת כל זוג. codekeeper_get_file עם query מוצא אותו.

  • כתיבה אטומית מול כותבים אחרים — מספר הגרסה עדיין נבחר בקריאה ואחריה כתיבה, ואין אינדקס ייחודי על (user_id, file_name, version). בתוך שירות ה-MCP כתיבות עוברות אחת-אחת (תור הכתיבה, _WRITE_POOL ב-mcp_server/server.py), אבל כתיבה מהוובאפ או מהבוט יכולה לנחות באמצע. הסגירה היא הכיוון שמתואר ב-#3391.

עריכה על מה שקראתם — expected_content_sha256

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

מה. codekeeper_edit_file, codekeeper_multi_edit_file ו-codekeeper_append_file מקבלים expected_content_sha256: ה-file.content_sha256 שהסוכן קרא (טביעת אצבע לתוכן — content_sha256 ו-content_changed). הוא מושווה ל-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 הייתה שולחת את הסוכן לקרוא מחדש ולשלוח שוב את אותו ערך שגוי.

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

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

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

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

הפריימר נושא שורת מצב שנייה, עם הפתקים שמחכה בהם תגובה לסוכן — ראו פריימר לסוכן — GET /api/agent/primer.

התראה. תגובה של סוכן שנכתבה רושמת אירוע note_comment ב-push_events — מזהי הפתק, השרשור והתגובה, איפה הפתק יושב, והכותב — באותו מנגנון של התראה כשסוכן שומר קובץ ובאותו דגל (MCP_PUSH_NOTIFICATIONS_ENABLED). סגירה בלי סיכום אינה תגובה, ותגובה של המשתמש אינה מתריעה לו. שולח הפוש בוובאפ קורא בזמן השליחה את שם הפתק ואת התגובה עצמה, ושולח התראה שהלחיצה עליה פותחת את השרשור — ואינה מאשרת תזכורת של הפתק. הפרטים, כולל חלון המעבר של Service Worker ישן: עובדי Push.

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

חשוב

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

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

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

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

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