שיתוף | צ'אט של Claude Code נחסם בנטפרי (418) בכל הודעה? הסבר, מניעה, וסקריפט שמתקן את הצ'אט לבד
-
שלום לכולם,
הודעה זו והסקריפטים שבה נכתבו על ידי Claude (הבינה המלאכותית של Anthropic), לבקשתי ובהנחייתי, ונבדקו כמתואר בסוף ההודעה.
למי ש-Claude Code (או Claude בדפדפן) "נתקע" אצלו בנטפרי עם
API Error: 418, אוJSON Parse error: Unexpected EOF, ואחרי "נסה שוב" זה חוזר שוב ושוב. הנה מה שלמדתי, ומה שבניתי כדי שלא אצטרך לתקן ידנית.לא יודעים איך להתקין? תנו ל-Claude Code לעשות את זה
אם אתם לא יודעים איך להתקין את הסקריפט וה-hook שבהודעה, פשוט העתיקו את הקישור להודעה הזו ל-Claude Code, והוא יתאים אותם למחשב שלכם. זו ההנחיה שאפשר לתת לו:
קרא את ההודעה בקישור הזה: https://mitmachim.top/topic/103008 התקן אצלי את שני הדברים שמופיעים בה: (1) הסקריפט netfree_recover.py ו-(2) ה-hook size_guard.py. התאם אותם למחשב שלי (נתיב פייתון, שם המשתמש, תיקיית .claude). כללים: - לפני כל שינוי תסביר לי בקצרה מה אתה הולך לעשות. - קודם גבה את הקובץ settings.json. את ה-hooks למזג לקובץ הקיים, בלי למחוק הגדרות שכבר יש לי. - את הסקריפט להריץ קודם עם --dry-run ולהראות לי את התוצאה. - את המשימה המתוזמנת (schtasks) אל תרשום בעצמך. תן לי את הפקודה המתאימה ואני אריץ אותה. - אל תשנה אף קובץ של צ'אט אמיתי בלי לשאול אותי. - אם אתה לא מצליח לפתוח את הקישור, תגיד לי, ואדביק לך את תוכן ההודעה.שימו לב: Claude Code (או האפליקציה) עלול לבקש אישור, או אפילו לסרב, לכתוב סקריפט שנוגע בקבצי ההיסטוריה של הצ'אטים. זה צפוי, ואפשר לאשר בעצמכם. לא נבדק על מחשבים אחרים.
למה זה קורה
בכל הודעה שאתם שולחים, כל היסטוריית הצ'אט נשלחת מחדש ל-api.anthropic.com, ונטפרי סורק אותה. אם קטע אחד בהיסטוריה (בדרך כלל תוצאה של כלי: קובץ שנקרא, דף, תמונה) הפעיל את הסינון, הוא ימשיך להפעיל אותו בכל הודעה אחריו. לכן "נסה שוב" לא עוזר, ולכן גם צ'אט חדש שנפתח כ"המשך" או מסיכום של הישן עלול לרשת את הבעיה.
מה מגדיל את הסיכון
- קריאת תמונות (גם תמימות) וצילומי מסך. התמונה נכנסת כ-base64 ונסרקת.
- קריאה של קובץ או ספר גדול בבת אחת, במיוחד עם הרבה טקסט רגיש.
- פלטים ארוכים של כלים.
- הדבקת דף החסימה של נטפרי לתוך הצ'אט. הוא נשאר בהיסטוריה וממשיך להרעיל.
מניעה (עובד אצלי)
- לקרוא במנות קטנות (עד כמה עשרות שורות), לא פרקים שלמים.
- לעבד תוכן רגיש בסקריפט מקומי, ולהביא ל-Claude רק סיכום.
- לא לצלם ולא להדביק דפי חסימה. לתאר אותם במילים.
- וכדי שלא אצטרך לזכור את זה בעצמי, יש לי hook אוטומטי (ההסבר והקוד מיד למטה).
Hook שמונע מראש כניסה של בלוקים גדולים
hook הוא סקריפט קטן ש-Claude Code מריץ אוטומטית לפני ואחרי כל כלי. אני משתמש בו כשומר גודל:
- לפני קריאה: אם Claude מנסה לקרוא קובץ גדול (מעל כ-60KB) בבת אחת, ה-hook חוסם ואומר לו לקרוא בחלקים (
offsetו-limit), לחפש בקובץ, או לעבד אותו בסקריפט מקומי. - לפני כתיבה: כתיבה של יותר מ-30,000 תווים בבת אחת נחסמת, וצריך לכתוב שלד ולהוסיף בחלקים. גם פלט גדול שנשלח אל נטפרי יכול לגרום לחסימה.
- אחרי כלי: אם התוצאה גדולה מ-25,000 תווים, הוא מוסיף ל-Claude תזכורת לעבוד במנות קטנות.
- אם הסקריפט עצמו נכשל, הוא לא חוסם כלום.
אפשר לשנות את שלושת המספרים שבראש הקובץ.
איך מתקינים:
- לשמור את הקוד למטה כ-
size_guard.pyבתיקייה.claude\hooksשבתיקיית המשתמש. - להוסיף ל-
.claude\settings.json(אם כבר יש שם"hooks"אז למזג, לא להכפיל):
"hooks": { "PreToolUse": [ { "matcher": "Read|Write", "hooks": [ { "type": "command", "command": "\"C:\\Users\\<שם המשתמש>\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" \"C:\\Users\\<שם המשתמש>\\.claude\\hooks\\size_guard.py\"", "timeout": 10 } ] } ], "PostToolUse": [ { "hooks": [ { "type": "command", "command": "\"C:\\Users\\<שם המשתמש>\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" \"C:\\Users\\<שם המשתמש>\\.claude\\hooks\\size_guard.py\"", "timeout": 10 } ] } ] }- לפתוח צ'אט חדש. ב-Claude Code אפשר לראות ב-
/hooksאם הוא נטען.
"""שומר גודל: מונע כניסה ויציאה של טקסט גדול מדי מההקשר (נטפרי חוסם צ'אט שמעביר בלוקים גדולים). PreToolUse : חוסם קריאה גדולה (Read בלי limit לקובץ גדול) וכתיבה גדולה (Write). PostToolUse : אם תוצאת כלי גדולה מדי - מזריק תזכורת לעבוד במנות ובסקריפט מקומי. כשל בסקריפט לא חוסם כלום (יוצאים בשקט). """ import json import os import sys MAX_READ_FILE_BYTES = 60_000 # Read בלי limit: מעל זה חוסמים MAX_WRITE_CHARS = 30_000 # Write: מעל זה מפצלים MAX_RESULT_CHARS = 25_000 # תוצאת כלי: מעל זה מזכירים SKIP_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".ipynb"} def deny(reason): print(json.dumps({"hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}, ensure_ascii=False)) sys.exit(0) def remind(text): print(json.dumps({"hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": text}}, ensure_ascii=False)) sys.exit(0) def main(): sys.stdout.reconfigure(encoding="utf-8") data = json.loads(sys.stdin.buffer.read().decode("utf-8")) event = data.get("hook_event_name", "") name = data.get("tool_name", "") inp = data.get("tool_input") or {} if event == "PreToolUse": if name == "Read": path = inp.get("file_path", "") if (inp.get("limit") is None and not inp.get("allow_large") and os.path.splitext(path)[1].lower() not in SKIP_EXT and os.path.isfile(path) and os.path.getsize(path) > MAX_READ_FILE_BYTES): deny(f"הקובץ גדול ({os.path.getsize(path) // 1000}KB). לקרוא בחלקים עם offset ו-limit, " "או לחפש בו עם Grep, או לעבד אותו בסקריפט מקומי ולהביא רק סיכום.") elif name == "Write": if len(inp.get("content", "")) > MAX_WRITE_CHARS: deny(f"כתיבה גדולה מדי בבת אחת (מעל {MAX_WRITE_CHARS} תווים). " "לכתוב שלד קטן ולהוסיף בחלקים עם Edit, או לבנות את הקובץ בסקריפט.") elif event == "PostToolUse": size = len(json.dumps(data.get("tool_response", ""), ensure_ascii=False)) if size > MAX_RESULT_CHARS: remind(f"תזכורת: תוצאת הכלי האחרון הייתה {size // 1000}K תווים. פלט/קלט גדול עלול לגרום לנטפרי " "לחסום את הצ'אט. מכאן: לקרוא ולהדפיס במנות קטנות, לעבד בסקריפט מקומי, " "ולהביא להקשר רק את מה שצריך.") try: main() except SystemExit: raise except Exception: sys.exit(0)חשוב לדעת: ה-hook מצמצם את הסיכון, לא מבטל אותו. גם קובץ קטן יכול להפעיל את הסינון אם התוכן שלו רגיש. את הגרסה הכללית שבהודעה (בלי החלק שלי לקריאה מספרייה מסוימת) בדקתי עכשיו עם קלטים מדומים: קובץ גדול נחסם, קריאה עם
limitעוברת, פלט גדול מקבל תזכורת, וכתיבה גדולה נחסמת.התיקון: סקריפט שמזהה צ'אט חסום ומחליף את הקטע המרעיל
Claude Code שומר כל צ'אט כקובץ מקומי (
~/.claude/projects/<פרויקט>/<מזהה>.jsonl), והקובץ הזה לא עובר דרך נטפרי. הסקריפט:- מזהה צ'אט שהשורה האחרונה בו היא
API Error: 418. - הולך אחורה אל תוצאות הכלי האחרונות (גם כמה במקביל), ומחליף אותן בהערה קצרה. כך ההיסטוריה כבר לא מפעילה את הסינון.
- לפני שהוא משנה, הוא שומר גיבוי מלא (
.bak-before-scrub) ואת הטקסט שהוסר (.removed.txt), וכותב בצורה בטוחה (קובץ זמני ואז החלפה). - הגנות: לא נוגע בצ'אט שכתבו בו ב-45 השניות האחרונות, רק בצ'אטים שהשתנו בשעתיים האחרונות, ועד 3 תיקונים בכל הרצה, כדי לא "להעיר" צ'אטים ישנים. הוא שומר את תאריך השינוי המקורי של הקובץ. אם החסימה היא לא אחרי תוצאת כלי, הוא רק רושם ביומן "דרוש טיפול ידני".
- הוא כותב ליומן
~/.claude/logs/netfree_recover.log, ואפשר להריץ אותו עם--dry-runכדי לראות מה היה מתוקן בלי לשנות כלום.
קודם כל תריצו עם
--dry-run:python netfree_recover.py --dry-runלהרצה אוטומטית כל 2 דקות (חלון CMD או PowerShell, לא Git Bash), עם
pythonwכדי שלא יקפוץ חלון. את הנתיבים להתאים אליכם (where pythonwמראה איפה הוא):schtasks /create /tn NetfreeRecover /sc minute /mo 2 /f /tr "\"C:\Users\<שם המשתמש>\AppData\Local\Programs\Python\Python312\pythonw.exe\" -I \"C:\Users\<שם המשתמש>\.claude\scripts\netfree_recover.py\""לביטול:
schtasks /delete /tn NetfreeRecover /fהקוד המלא
(לשמור כ-
netfree_recover.pyבתיקייה.claude\scriptsשבתיקיית המשתמש. פייתון 3, בלי התקנת חבילות.)# -*- coding: utf-8 -*- """מזהה צ'אט של Claude Code שנחסם בנטפרי (API Error 418) ומתקן אותו. הרצה חד-פעמית (מתוזמנת כל ~2 דקות). לכל קובץ תמליל אחרון-לאחרונה: 1. "נחסם" = שורת השיחה האחרונה היא assistant שטקסטה מתחיל ב-API Error: 418. 2. הולכים אחורה (מדלגים על 418 ועל הודעות משתמש רגילות) עד שורת user עם תוצאות כלי: זו הרעל. אם נתקלים קודם בתשובה רגילה של Claude -> רק יומן "דרוש טיפול ידני". 3. כל תוצאות הכלי באותה קבוצת שורות (גם במקביל, גם תמונות) מוחלפות בהערה, ו-toolUseResult מוסר. שאר השורות נשארות כפי שהן (בית-בבית). 4. גיבוי פעם אחת (.bak-before-scrub), הטקסט שהוסר ל-.removed.txt, כתיבה אטומית. שימוש: pythonw netfree_recover.py [--dir D] [--dry-run] [--min-age S] [--max-age-hours H] [--log-dir L] """ import argparse import glob import json import os import re import shutil import sys import time HOME = os.path.expanduser('~') DEFAULT_DIR = os.path.join(HOME, '.claude', 'projects') DEFAULT_LOG_DIR = os.path.join(HOME, '.claude', 'logs') NOTE = "[הקטע הוסר מהצ'אט כי נטפרי חסם אותו (418). לא לקרוא שוב את אותו טווח בספר.]" ERR_RE = re.compile(r'^\s*API Error:\s*418\b') LOCK_STALE_SECONDS = 600 def say(msg, enabled): if enabled and sys.stdout: try: print(msg) except Exception: pass class Logger: def __init__(self, log_dir, dry_run, echo): self.path = os.path.join(log_dir, 'netfree_recover.log') self.prefix = '[dry-run] ' if dry_run else '' self.echo = echo def write(self, text): line = '%s %s%s' % (time.strftime('%Y-%m-%d %H:%M:%S'), self.prefix, text) try: with open(self.path, 'a', encoding='utf-8') as f: f.write(line + '\n') except Exception: pass say(line, self.echo) # ---------- ניתוח שורות ---------- def parse(line): try: row = json.loads(line.decode('utf-8')) except Exception: return None return row if isinstance(row, dict) else None def content_of(row): msg = row.get('message') return msg.get('content') if isinstance(msg, dict) else None def assistant_text(row): c = content_of(row) if isinstance(c, str): return c if isinstance(c, list): return ' '.join(b.get('text', '') for b in c if isinstance(b, dict) and b.get('type') == 'text') return '' def is_418(row): return row.get('type') == 'assistant' and ( bool(ERR_RE.match(assistant_text(row))) or row.get('apiErrorStatus') == 418) def result_blocks(row): c = content_of(row) if row.get('type') != 'user' or not isinstance(c, list): return [] return [b for b in c if isinstance(b, dict) and b.get('type') == 'tool_result'] def block_text(block): c = block.get('content') if isinstance(c, str): return c parts = [] for x in c or []: if not isinstance(x, dict): continue if x.get('type') == 'text': parts.append(x.get('text', '')) elif x.get('type') == 'image': parts.append('[תמונה]') return '\n'.join(parts) def already_note(block): return block_text(block).strip() == NOTE def analyse(lines): """מחזיר (kind, info). kind: ok | skip | manual | already | poison.""" idx = [i for i, l in enumerate(lines) if l.strip()] if not idx: return 'ok', None last_nonempty = parse(lines[idx[-1]]) if last_nonempty is None: return 'skip', 'שורה אחרונה לא תקינה (אולי באמצע כתיבה)' # שורת השיחה האחרונה conv = None rows = {} for i in reversed(idx): r = parse(lines[i]) rows[i] = r if r and r.get('type') in ('user', 'assistant'): conv = i break if conv is None or not is_418(rows[conv]): return 'ok', None # הליכה אחורה אל שורת הרעל i = conv poison = None while i >= 0: i -= 1 if i < 0 or not lines[i].strip(): continue r = parse(lines[i]) if not r or r.get('type') not in ('user', 'assistant'): continue if r['type'] == 'assistant': if is_418(r): continue return 'manual', i if result_blocks(r): poison = i break # הודעת משתמש רגילה ("נסה שוב") - ממשיכים אחורה if poison is None: return 'manual', -1 # קבוצה רציפה של שורות user עם תוצאות כלי (תוצאות מקבילות בשורות נפרדות) group = [poison] j = poison while j > 0: j -= 1 if not lines[j].strip(): continue r = parse(lines[j]) if not r or r.get('type') not in ('user', 'assistant'): continue if r['type'] == 'user' and result_blocks(r): group.append(j) continue break group.sort() if all(already_note(b) for g in group for b in result_blocks(parse(lines[g]))): return 'already', poison return 'poison', group # ---------- תיקון ---------- def scrub_lines(lines, group): """מחזיר (שורות חדשות, כמה תוצאות, כמה תווים, טקסט שהוסר).""" out = list(lines) n_results = 0 n_chars = 0 removed = [] for g in group: row = parse(lines[g]) changed = False for b in result_blocks(row): if already_note(b): continue text = block_text(b) n_results += 1 n_chars += len(text) removed.append('--- שורה %d, tool_use_id=%s ---\n%s' % (g, b.get('tool_use_id'), text)) b['content'] = NOTE changed = True if 'toolUseResult' in row: del row['toolUseResult'] changed = True if changed: ending = b'\r\n' if lines[g].endswith(b'\r\n') else (b'\n' if lines[g].endswith(b'\n') else b'') out[g] = json.dumps(row, ensure_ascii=False, separators=(',', ':')).encode('utf-8') + ending return out, n_results, n_chars, '\n\n'.join(removed) def stat_sig(path): st = os.stat(path) return st.st_mtime_ns, st.st_size def process(path, args, log, state): name = os.path.basename(path) sig0 = stat_sig(path) with open(path, 'rb') as f: data = f.read() if stat_sig(path) != sig0: return # משתנה תוך כדי קריאה lines = data.splitlines(keepends=True) kind, info = analyse(lines) if kind in ('ok', 'skip'): state.pop(path, None) return if kind in ('manual', 'already'): signature = '%s:%s' % (kind, info) if state.get(path) == signature: return if kind == 'already' and state.get(path) == 'fixed:%d:%d' % (info, sig0[1]): return # זה הקובץ שרק תיקנו ועוד לא נעשה בו ניסיון חוזר if kind == 'manual': log.write('%s | נחסם (418) אבל לא אחרי תוצאת כלי (שורה %s) | דרוש טיפול ידני, לא שונה' % (name, info + 1 if info >= 0 else '?')) else: log.write('%s | שורה %d כבר הוחלפה בהערה והשגיאה נמשכת | לא שונה' % (name, info + 1)) if not args.dry_run: state[path] = signature return # poison group = info new_lines, n_results, n_chars, removed = scrub_lines(lines, group) where = ','.join(str(g + 1) for g in group) if args.fixed >= args.max_fixes: if args.fixed == args.max_fixes: args.fixed += 1 log.write('יותר מ-%d צ\'אטים חסומים בבת אחת, לא נוגע בשאר (כדי לא להעיר צ\'אטים ישנים). ' 'אפשר להריץ ידנית עם --max-fixes גבוה יותר' % args.max_fixes) return args.fixed += 1 if args.dry_run: log.write('%s | היה מתוקן: שורות %s | %d תוצאות | %d תווים' % (name, where, n_results, n_chars)) return backup = path + '.bak-before-scrub' if not os.path.exists(backup): shutil.copy2(path, backup) with open(path + '.removed.txt', 'a', encoding='utf-8') as f: f.write('===== %s =====\n%s\n\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), removed)) tmp = path + '.scrub-tmp' with open(tmp, 'wb') as f: f.write(b''.join(new_lines)) if stat_sig(path) != sig0: # מישהו כתב בינתיים os.remove(tmp) log.write('%s | הקובץ השתנה בזמן העבודה, ננסה ברוץ הבא' % name) return try: os.replace(tmp, path) # שומרים את תאריך השינוי המקורי: הצ'אט לא "קופץ" לראש הרשימה כאילו נעשה בו שימוש os.utime(path, ns=(sig0[0], sig0[0])) except OSError as e: try: os.remove(tmp) except OSError: pass log.write('%s | הקובץ נעול (%s), ננסה ברוץ הבא' % (name, e.__class__.__name__)) return state[path] = 'fixed:%d:%d' % (group[-1], os.path.getsize(path)) log.write('%s | תוקן: שורות %s | %d תוצאות | %d תווים' % (name, where, n_results, n_chars)) # ---------- ראשי ---------- def load_state(path): try: with open(path, encoding='utf-8') as f: d = json.load(f) return d if isinstance(d, dict) else {} except Exception: return {} def save_state(path, state): tmp = path + '.tmp' with open(tmp, 'w', encoding='utf-8') as f: json.dump(state, f, ensure_ascii=False, indent=1) os.replace(tmp, path) def acquire_lock(path): try: if os.path.exists(path) and time.time() - os.path.getmtime(path) > LOCK_STALE_SECONDS: os.remove(path) fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) os.close(fd) return True except OSError: return False def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument('--dir', default=DEFAULT_DIR) ap.add_argument('--dry-run', action='store_true') ap.add_argument('--min-age', type=float, default=45) ap.add_argument('--max-age-hours', type=float, default=2) ap.add_argument('--max-fixes', type=int, default=3) ap.add_argument('--log-dir', default=DEFAULT_LOG_DIR) args = ap.parse_args(argv) args.fixed = 0 os.makedirs(args.log_dir, exist_ok=True) log = Logger(args.log_dir, args.dry_run, echo=args.dry_run) lock = os.path.join(args.log_dir, 'netfree_recover.lock') if not acquire_lock(lock): return 0 state_path = os.path.join(args.log_dir, 'netfree_recover_state.json') state = load_state(state_path) try: now = time.time() # קבצי הצ'אט הראשיים וגם תמלילי סוכני העזר (<צ'אט>/subagents/agent-*.jsonl) paths = glob.glob(os.path.join(args.dir, '*', '*.jsonl')) paths += glob.glob(os.path.join(args.dir, '*', '*', 'subagents', 'agent-*.jsonl')) for path in paths: try: age = now - os.path.getmtime(path) if age < args.min_age or age > args.max_age_hours * 3600: continue process(path, args, log, state) except Exception as e: log.write('%s | שגיאה: %s: %s' % (os.path.basename(path), e.__class__.__name__, e)) state = {p: v for p, v in state.items() if os.path.exists(p)} if not args.dry_run: save_state(state_path, state) finally: try: os.remove(lock) except OSError: pass return 0 if __name__ == '__main__': sys.exit(main())מגבלות, בכנות
- נבדק על תמלילים מומצאים (33 בדיקות, כולל כמה תוצאות במקביל ותמונות), והורץ בפועל אצלי על שני קבצי צ'אט אמיתיים (של סוכני עזר). לא נבדק על גרסאות אחרות של Claude Code. הוא תלוי בפורמט קבצי הצ'אט של Claude Code, ואם הפורמט ישתנה הוא עלול להפסיק לעבוד.
- צ'אט שפתוח כרגע בחלון חי לא תמיד חוזר לפעול גם אחרי התיקון, כי כנראה ההיסטוריה הישנה נשארת בזיכרון שלו. אצלי, אחרי תיקון ידני באותה שיטה, צ'אט אחד חזר לפעול וצ'אט אחד לא. במקרה כזה פותחים צ'אט חדש (לא "המשך") וממשיכים מנקודת העצירה.
- הסקריפט משנה את קבצי ההיסטוריה של הצ'אטים. הגיבוי נשמר לידם, אבל האחריות עליכם.
- Claude "ישכח" מה היה בתוצאה שהוסרה. ההערה שנכתבה במקומה אומרת לו לא לקרוא שוב את אותו טווח.
- הוא לא מונע חסימות, הוא רק מתקן אחרי. המניעה היא במה שנכנס להקשר.
אשמח מאוד אם למישהו יש שיטה יעילה יותר להתמודד עם זה, או תיקונים והערות לסקריפט. שימו לב: מה שכתבתי כאן הוא ניקוי של ההיסטוריה המקומית ומניעה של מה שנכנס להקשר, ולא עקיפה או פריצה של הסינון עצמו.
-
שלום לכולם,
הודעה זו והסקריפטים שבה נכתבו על ידי Claude (הבינה המלאכותית של Anthropic), לבקשתי ובהנחייתי, ונבדקו כמתואר בסוף ההודעה.
למי ש-Claude Code (או Claude בדפדפן) "נתקע" אצלו בנטפרי עם
API Error: 418, אוJSON Parse error: Unexpected EOF, ואחרי "נסה שוב" זה חוזר שוב ושוב. הנה מה שלמדתי, ומה שבניתי כדי שלא אצטרך לתקן ידנית.לא יודעים איך להתקין? תנו ל-Claude Code לעשות את זה
אם אתם לא יודעים איך להתקין את הסקריפט וה-hook שבהודעה, פשוט העתיקו את הקישור להודעה הזו ל-Claude Code, והוא יתאים אותם למחשב שלכם. זו ההנחיה שאפשר לתת לו:
קרא את ההודעה בקישור הזה: https://mitmachim.top/topic/103008 התקן אצלי את שני הדברים שמופיעים בה: (1) הסקריפט netfree_recover.py ו-(2) ה-hook size_guard.py. התאם אותם למחשב שלי (נתיב פייתון, שם המשתמש, תיקיית .claude). כללים: - לפני כל שינוי תסביר לי בקצרה מה אתה הולך לעשות. - קודם גבה את הקובץ settings.json. את ה-hooks למזג לקובץ הקיים, בלי למחוק הגדרות שכבר יש לי. - את הסקריפט להריץ קודם עם --dry-run ולהראות לי את התוצאה. - את המשימה המתוזמנת (schtasks) אל תרשום בעצמך. תן לי את הפקודה המתאימה ואני אריץ אותה. - אל תשנה אף קובץ של צ'אט אמיתי בלי לשאול אותי. - אם אתה לא מצליח לפתוח את הקישור, תגיד לי, ואדביק לך את תוכן ההודעה.שימו לב: Claude Code (או האפליקציה) עלול לבקש אישור, או אפילו לסרב, לכתוב סקריפט שנוגע בקבצי ההיסטוריה של הצ'אטים. זה צפוי, ואפשר לאשר בעצמכם. לא נבדק על מחשבים אחרים.
למה זה קורה
בכל הודעה שאתם שולחים, כל היסטוריית הצ'אט נשלחת מחדש ל-api.anthropic.com, ונטפרי סורק אותה. אם קטע אחד בהיסטוריה (בדרך כלל תוצאה של כלי: קובץ שנקרא, דף, תמונה) הפעיל את הסינון, הוא ימשיך להפעיל אותו בכל הודעה אחריו. לכן "נסה שוב" לא עוזר, ולכן גם צ'אט חדש שנפתח כ"המשך" או מסיכום של הישן עלול לרשת את הבעיה.
מה מגדיל את הסיכון
- קריאת תמונות (גם תמימות) וצילומי מסך. התמונה נכנסת כ-base64 ונסרקת.
- קריאה של קובץ או ספר גדול בבת אחת, במיוחד עם הרבה טקסט רגיש.
- פלטים ארוכים של כלים.
- הדבקת דף החסימה של נטפרי לתוך הצ'אט. הוא נשאר בהיסטוריה וממשיך להרעיל.
מניעה (עובד אצלי)
- לקרוא במנות קטנות (עד כמה עשרות שורות), לא פרקים שלמים.
- לעבד תוכן רגיש בסקריפט מקומי, ולהביא ל-Claude רק סיכום.
- לא לצלם ולא להדביק דפי חסימה. לתאר אותם במילים.
- וכדי שלא אצטרך לזכור את זה בעצמי, יש לי hook אוטומטי (ההסבר והקוד מיד למטה).
Hook שמונע מראש כניסה של בלוקים גדולים
hook הוא סקריפט קטן ש-Claude Code מריץ אוטומטית לפני ואחרי כל כלי. אני משתמש בו כשומר גודל:
- לפני קריאה: אם Claude מנסה לקרוא קובץ גדול (מעל כ-60KB) בבת אחת, ה-hook חוסם ואומר לו לקרוא בחלקים (
offsetו-limit), לחפש בקובץ, או לעבד אותו בסקריפט מקומי. - לפני כתיבה: כתיבה של יותר מ-30,000 תווים בבת אחת נחסמת, וצריך לכתוב שלד ולהוסיף בחלקים. גם פלט גדול שנשלח אל נטפרי יכול לגרום לחסימה.
- אחרי כלי: אם התוצאה גדולה מ-25,000 תווים, הוא מוסיף ל-Claude תזכורת לעבוד במנות קטנות.
- אם הסקריפט עצמו נכשל, הוא לא חוסם כלום.
אפשר לשנות את שלושת המספרים שבראש הקובץ.
איך מתקינים:
- לשמור את הקוד למטה כ-
size_guard.pyבתיקייה.claude\hooksשבתיקיית המשתמש. - להוסיף ל-
.claude\settings.json(אם כבר יש שם"hooks"אז למזג, לא להכפיל):
"hooks": { "PreToolUse": [ { "matcher": "Read|Write", "hooks": [ { "type": "command", "command": "\"C:\\Users\\<שם המשתמש>\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" \"C:\\Users\\<שם המשתמש>\\.claude\\hooks\\size_guard.py\"", "timeout": 10 } ] } ], "PostToolUse": [ { "hooks": [ { "type": "command", "command": "\"C:\\Users\\<שם המשתמש>\\AppData\\Local\\Programs\\Python\\Python312\\python.exe\" \"C:\\Users\\<שם המשתמש>\\.claude\\hooks\\size_guard.py\"", "timeout": 10 } ] } ] }- לפתוח צ'אט חדש. ב-Claude Code אפשר לראות ב-
/hooksאם הוא נטען.
"""שומר גודל: מונע כניסה ויציאה של טקסט גדול מדי מההקשר (נטפרי חוסם צ'אט שמעביר בלוקים גדולים). PreToolUse : חוסם קריאה גדולה (Read בלי limit לקובץ גדול) וכתיבה גדולה (Write). PostToolUse : אם תוצאת כלי גדולה מדי - מזריק תזכורת לעבוד במנות ובסקריפט מקומי. כשל בסקריפט לא חוסם כלום (יוצאים בשקט). """ import json import os import sys MAX_READ_FILE_BYTES = 60_000 # Read בלי limit: מעל זה חוסמים MAX_WRITE_CHARS = 30_000 # Write: מעל זה מפצלים MAX_RESULT_CHARS = 25_000 # תוצאת כלי: מעל זה מזכירים SKIP_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".ipynb"} def deny(reason): print(json.dumps({"hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}, ensure_ascii=False)) sys.exit(0) def remind(text): print(json.dumps({"hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": text}}, ensure_ascii=False)) sys.exit(0) def main(): sys.stdout.reconfigure(encoding="utf-8") data = json.loads(sys.stdin.buffer.read().decode("utf-8")) event = data.get("hook_event_name", "") name = data.get("tool_name", "") inp = data.get("tool_input") or {} if event == "PreToolUse": if name == "Read": path = inp.get("file_path", "") if (inp.get("limit") is None and not inp.get("allow_large") and os.path.splitext(path)[1].lower() not in SKIP_EXT and os.path.isfile(path) and os.path.getsize(path) > MAX_READ_FILE_BYTES): deny(f"הקובץ גדול ({os.path.getsize(path) // 1000}KB). לקרוא בחלקים עם offset ו-limit, " "או לחפש בו עם Grep, או לעבד אותו בסקריפט מקומי ולהביא רק סיכום.") elif name == "Write": if len(inp.get("content", "")) > MAX_WRITE_CHARS: deny(f"כתיבה גדולה מדי בבת אחת (מעל {MAX_WRITE_CHARS} תווים). " "לכתוב שלד קטן ולהוסיף בחלקים עם Edit, או לבנות את הקובץ בסקריפט.") elif event == "PostToolUse": size = len(json.dumps(data.get("tool_response", ""), ensure_ascii=False)) if size > MAX_RESULT_CHARS: remind(f"תזכורת: תוצאת הכלי האחרון הייתה {size // 1000}K תווים. פלט/קלט גדול עלול לגרום לנטפרי " "לחסום את הצ'אט. מכאן: לקרוא ולהדפיס במנות קטנות, לעבד בסקריפט מקומי, " "ולהביא להקשר רק את מה שצריך.") try: main() except SystemExit: raise except Exception: sys.exit(0)חשוב לדעת: ה-hook מצמצם את הסיכון, לא מבטל אותו. גם קובץ קטן יכול להפעיל את הסינון אם התוכן שלו רגיש. את הגרסה הכללית שבהודעה (בלי החלק שלי לקריאה מספרייה מסוימת) בדקתי עכשיו עם קלטים מדומים: קובץ גדול נחסם, קריאה עם
limitעוברת, פלט גדול מקבל תזכורת, וכתיבה גדולה נחסמת.התיקון: סקריפט שמזהה צ'אט חסום ומחליף את הקטע המרעיל
Claude Code שומר כל צ'אט כקובץ מקומי (
~/.claude/projects/<פרויקט>/<מזהה>.jsonl), והקובץ הזה לא עובר דרך נטפרי. הסקריפט:- מזהה צ'אט שהשורה האחרונה בו היא
API Error: 418. - הולך אחורה אל תוצאות הכלי האחרונות (גם כמה במקביל), ומחליף אותן בהערה קצרה. כך ההיסטוריה כבר לא מפעילה את הסינון.
- לפני שהוא משנה, הוא שומר גיבוי מלא (
.bak-before-scrub) ואת הטקסט שהוסר (.removed.txt), וכותב בצורה בטוחה (קובץ זמני ואז החלפה). - הגנות: לא נוגע בצ'אט שכתבו בו ב-45 השניות האחרונות, רק בצ'אטים שהשתנו בשעתיים האחרונות, ועד 3 תיקונים בכל הרצה, כדי לא "להעיר" צ'אטים ישנים. הוא שומר את תאריך השינוי המקורי של הקובץ. אם החסימה היא לא אחרי תוצאת כלי, הוא רק רושם ביומן "דרוש טיפול ידני".
- הוא כותב ליומן
~/.claude/logs/netfree_recover.log, ואפשר להריץ אותו עם--dry-runכדי לראות מה היה מתוקן בלי לשנות כלום.
קודם כל תריצו עם
--dry-run:python netfree_recover.py --dry-runלהרצה אוטומטית כל 2 דקות (חלון CMD או PowerShell, לא Git Bash), עם
pythonwכדי שלא יקפוץ חלון. את הנתיבים להתאים אליכם (where pythonwמראה איפה הוא):schtasks /create /tn NetfreeRecover /sc minute /mo 2 /f /tr "\"C:\Users\<שם המשתמש>\AppData\Local\Programs\Python\Python312\pythonw.exe\" -I \"C:\Users\<שם המשתמש>\.claude\scripts\netfree_recover.py\""לביטול:
schtasks /delete /tn NetfreeRecover /fהקוד המלא
(לשמור כ-
netfree_recover.pyבתיקייה.claude\scriptsשבתיקיית המשתמש. פייתון 3, בלי התקנת חבילות.)# -*- coding: utf-8 -*- """מזהה צ'אט של Claude Code שנחסם בנטפרי (API Error 418) ומתקן אותו. הרצה חד-פעמית (מתוזמנת כל ~2 דקות). לכל קובץ תמליל אחרון-לאחרונה: 1. "נחסם" = שורת השיחה האחרונה היא assistant שטקסטה מתחיל ב-API Error: 418. 2. הולכים אחורה (מדלגים על 418 ועל הודעות משתמש רגילות) עד שורת user עם תוצאות כלי: זו הרעל. אם נתקלים קודם בתשובה רגילה של Claude -> רק יומן "דרוש טיפול ידני". 3. כל תוצאות הכלי באותה קבוצת שורות (גם במקביל, גם תמונות) מוחלפות בהערה, ו-toolUseResult מוסר. שאר השורות נשארות כפי שהן (בית-בבית). 4. גיבוי פעם אחת (.bak-before-scrub), הטקסט שהוסר ל-.removed.txt, כתיבה אטומית. שימוש: pythonw netfree_recover.py [--dir D] [--dry-run] [--min-age S] [--max-age-hours H] [--log-dir L] """ import argparse import glob import json import os import re import shutil import sys import time HOME = os.path.expanduser('~') DEFAULT_DIR = os.path.join(HOME, '.claude', 'projects') DEFAULT_LOG_DIR = os.path.join(HOME, '.claude', 'logs') NOTE = "[הקטע הוסר מהצ'אט כי נטפרי חסם אותו (418). לא לקרוא שוב את אותו טווח בספר.]" ERR_RE = re.compile(r'^\s*API Error:\s*418\b') LOCK_STALE_SECONDS = 600 def say(msg, enabled): if enabled and sys.stdout: try: print(msg) except Exception: pass class Logger: def __init__(self, log_dir, dry_run, echo): self.path = os.path.join(log_dir, 'netfree_recover.log') self.prefix = '[dry-run] ' if dry_run else '' self.echo = echo def write(self, text): line = '%s %s%s' % (time.strftime('%Y-%m-%d %H:%M:%S'), self.prefix, text) try: with open(self.path, 'a', encoding='utf-8') as f: f.write(line + '\n') except Exception: pass say(line, self.echo) # ---------- ניתוח שורות ---------- def parse(line): try: row = json.loads(line.decode('utf-8')) except Exception: return None return row if isinstance(row, dict) else None def content_of(row): msg = row.get('message') return msg.get('content') if isinstance(msg, dict) else None def assistant_text(row): c = content_of(row) if isinstance(c, str): return c if isinstance(c, list): return ' '.join(b.get('text', '') for b in c if isinstance(b, dict) and b.get('type') == 'text') return '' def is_418(row): return row.get('type') == 'assistant' and ( bool(ERR_RE.match(assistant_text(row))) or row.get('apiErrorStatus') == 418) def result_blocks(row): c = content_of(row) if row.get('type') != 'user' or not isinstance(c, list): return [] return [b for b in c if isinstance(b, dict) and b.get('type') == 'tool_result'] def block_text(block): c = block.get('content') if isinstance(c, str): return c parts = [] for x in c or []: if not isinstance(x, dict): continue if x.get('type') == 'text': parts.append(x.get('text', '')) elif x.get('type') == 'image': parts.append('[תמונה]') return '\n'.join(parts) def already_note(block): return block_text(block).strip() == NOTE def analyse(lines): """מחזיר (kind, info). kind: ok | skip | manual | already | poison.""" idx = [i for i, l in enumerate(lines) if l.strip()] if not idx: return 'ok', None last_nonempty = parse(lines[idx[-1]]) if last_nonempty is None: return 'skip', 'שורה אחרונה לא תקינה (אולי באמצע כתיבה)' # שורת השיחה האחרונה conv = None rows = {} for i in reversed(idx): r = parse(lines[i]) rows[i] = r if r and r.get('type') in ('user', 'assistant'): conv = i break if conv is None or not is_418(rows[conv]): return 'ok', None # הליכה אחורה אל שורת הרעל i = conv poison = None while i >= 0: i -= 1 if i < 0 or not lines[i].strip(): continue r = parse(lines[i]) if not r or r.get('type') not in ('user', 'assistant'): continue if r['type'] == 'assistant': if is_418(r): continue return 'manual', i if result_blocks(r): poison = i break # הודעת משתמש רגילה ("נסה שוב") - ממשיכים אחורה if poison is None: return 'manual', -1 # קבוצה רציפה של שורות user עם תוצאות כלי (תוצאות מקבילות בשורות נפרדות) group = [poison] j = poison while j > 0: j -= 1 if not lines[j].strip(): continue r = parse(lines[j]) if not r or r.get('type') not in ('user', 'assistant'): continue if r['type'] == 'user' and result_blocks(r): group.append(j) continue break group.sort() if all(already_note(b) for g in group for b in result_blocks(parse(lines[g]))): return 'already', poison return 'poison', group # ---------- תיקון ---------- def scrub_lines(lines, group): """מחזיר (שורות חדשות, כמה תוצאות, כמה תווים, טקסט שהוסר).""" out = list(lines) n_results = 0 n_chars = 0 removed = [] for g in group: row = parse(lines[g]) changed = False for b in result_blocks(row): if already_note(b): continue text = block_text(b) n_results += 1 n_chars += len(text) removed.append('--- שורה %d, tool_use_id=%s ---\n%s' % (g, b.get('tool_use_id'), text)) b['content'] = NOTE changed = True if 'toolUseResult' in row: del row['toolUseResult'] changed = True if changed: ending = b'\r\n' if lines[g].endswith(b'\r\n') else (b'\n' if lines[g].endswith(b'\n') else b'') out[g] = json.dumps(row, ensure_ascii=False, separators=(',', ':')).encode('utf-8') + ending return out, n_results, n_chars, '\n\n'.join(removed) def stat_sig(path): st = os.stat(path) return st.st_mtime_ns, st.st_size def process(path, args, log, state): name = os.path.basename(path) sig0 = stat_sig(path) with open(path, 'rb') as f: data = f.read() if stat_sig(path) != sig0: return # משתנה תוך כדי קריאה lines = data.splitlines(keepends=True) kind, info = analyse(lines) if kind in ('ok', 'skip'): state.pop(path, None) return if kind in ('manual', 'already'): signature = '%s:%s' % (kind, info) if state.get(path) == signature: return if kind == 'already' and state.get(path) == 'fixed:%d:%d' % (info, sig0[1]): return # זה הקובץ שרק תיקנו ועוד לא נעשה בו ניסיון חוזר if kind == 'manual': log.write('%s | נחסם (418) אבל לא אחרי תוצאת כלי (שורה %s) | דרוש טיפול ידני, לא שונה' % (name, info + 1 if info >= 0 else '?')) else: log.write('%s | שורה %d כבר הוחלפה בהערה והשגיאה נמשכת | לא שונה' % (name, info + 1)) if not args.dry_run: state[path] = signature return # poison group = info new_lines, n_results, n_chars, removed = scrub_lines(lines, group) where = ','.join(str(g + 1) for g in group) if args.fixed >= args.max_fixes: if args.fixed == args.max_fixes: args.fixed += 1 log.write('יותר מ-%d צ\'אטים חסומים בבת אחת, לא נוגע בשאר (כדי לא להעיר צ\'אטים ישנים). ' 'אפשר להריץ ידנית עם --max-fixes גבוה יותר' % args.max_fixes) return args.fixed += 1 if args.dry_run: log.write('%s | היה מתוקן: שורות %s | %d תוצאות | %d תווים' % (name, where, n_results, n_chars)) return backup = path + '.bak-before-scrub' if not os.path.exists(backup): shutil.copy2(path, backup) with open(path + '.removed.txt', 'a', encoding='utf-8') as f: f.write('===== %s =====\n%s\n\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), removed)) tmp = path + '.scrub-tmp' with open(tmp, 'wb') as f: f.write(b''.join(new_lines)) if stat_sig(path) != sig0: # מישהו כתב בינתיים os.remove(tmp) log.write('%s | הקובץ השתנה בזמן העבודה, ננסה ברוץ הבא' % name) return try: os.replace(tmp, path) # שומרים את תאריך השינוי המקורי: הצ'אט לא "קופץ" לראש הרשימה כאילו נעשה בו שימוש os.utime(path, ns=(sig0[0], sig0[0])) except OSError as e: try: os.remove(tmp) except OSError: pass log.write('%s | הקובץ נעול (%s), ננסה ברוץ הבא' % (name, e.__class__.__name__)) return state[path] = 'fixed:%d:%d' % (group[-1], os.path.getsize(path)) log.write('%s | תוקן: שורות %s | %d תוצאות | %d תווים' % (name, where, n_results, n_chars)) # ---------- ראשי ---------- def load_state(path): try: with open(path, encoding='utf-8') as f: d = json.load(f) return d if isinstance(d, dict) else {} except Exception: return {} def save_state(path, state): tmp = path + '.tmp' with open(tmp, 'w', encoding='utf-8') as f: json.dump(state, f, ensure_ascii=False, indent=1) os.replace(tmp, path) def acquire_lock(path): try: if os.path.exists(path) and time.time() - os.path.getmtime(path) > LOCK_STALE_SECONDS: os.remove(path) fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) os.close(fd) return True except OSError: return False def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument('--dir', default=DEFAULT_DIR) ap.add_argument('--dry-run', action='store_true') ap.add_argument('--min-age', type=float, default=45) ap.add_argument('--max-age-hours', type=float, default=2) ap.add_argument('--max-fixes', type=int, default=3) ap.add_argument('--log-dir', default=DEFAULT_LOG_DIR) args = ap.parse_args(argv) args.fixed = 0 os.makedirs(args.log_dir, exist_ok=True) log = Logger(args.log_dir, args.dry_run, echo=args.dry_run) lock = os.path.join(args.log_dir, 'netfree_recover.lock') if not acquire_lock(lock): return 0 state_path = os.path.join(args.log_dir, 'netfree_recover_state.json') state = load_state(state_path) try: now = time.time() # קבצי הצ'אט הראשיים וגם תמלילי סוכני העזר (<צ'אט>/subagents/agent-*.jsonl) paths = glob.glob(os.path.join(args.dir, '*', '*.jsonl')) paths += glob.glob(os.path.join(args.dir, '*', '*', 'subagents', 'agent-*.jsonl')) for path in paths: try: age = now - os.path.getmtime(path) if age < args.min_age or age > args.max_age_hours * 3600: continue process(path, args, log, state) except Exception as e: log.write('%s | שגיאה: %s: %s' % (os.path.basename(path), e.__class__.__name__, e)) state = {p: v for p, v in state.items() if os.path.exists(p)} if not args.dry_run: save_state(state_path, state) finally: try: os.remove(lock) except OSError: pass return 0 if __name__ == '__main__': sys.exit(main())מגבלות, בכנות
- נבדק על תמלילים מומצאים (33 בדיקות, כולל כמה תוצאות במקביל ותמונות), והורץ בפועל אצלי על שני קבצי צ'אט אמיתיים (של סוכני עזר). לא נבדק על גרסאות אחרות של Claude Code. הוא תלוי בפורמט קבצי הצ'אט של Claude Code, ואם הפורמט ישתנה הוא עלול להפסיק לעבוד.
- צ'אט שפתוח כרגע בחלון חי לא תמיד חוזר לפעול גם אחרי התיקון, כי כנראה ההיסטוריה הישנה נשארת בזיכרון שלו. אצלי, אחרי תיקון ידני באותה שיטה, צ'אט אחד חזר לפעול וצ'אט אחד לא. במקרה כזה פותחים צ'אט חדש (לא "המשך") וממשיכים מנקודת העצירה.
- הסקריפט משנה את קבצי ההיסטוריה של הצ'אטים. הגיבוי נשמר לידם, אבל האחריות עליכם.
- Claude "ישכח" מה היה בתוצאה שהוסרה. ההערה שנכתבה במקומה אומרת לו לא לקרוא שוב את אותו טווח.
- הוא לא מונע חסימות, הוא רק מתקן אחרי. המניעה היא במה שנכנס להקשר.
אשמח מאוד אם למישהו יש שיטה יעילה יותר להתמודד עם זה, או תיקונים והערות לסקריפט. שימו לב: מה שכתבתי כאן הוא ניקוי של ההיסטוריה המקומית ומניעה של מה שנכנס להקשר, ולא עקיפה או פריצה של הסינון עצמו.
שלום! נראה שהשיחה הזו מעניינת אותך, אבל עדיין אין לך חשבון.
נמאס לכם לגלול בין אותם הפוסטים בכל ביקור? כשנרשמים לחשבון, תמיד תחזרו בדיוק למקום שבו הייתם קודם, ותוכלו לבחור לקבל התראות על תגובות חדשות (בין אם במייל, ובין אם בהתראת פוש). תוכלו גם לשמור סימניות ולפרגן ב-upvote לפוסטים כדי להביע הערכה לחברי קהילה אחרים.
בעזרת התרומה שלך, הפוסט הזה יכול להיות אפילו טוב יותר 💗
הרשמה התחברות