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