Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
- **智能检测** — 两级拒绝检测策略(强短语全文匹配 + 弱关键词开头匹配),低误报率
- **AI 智能改写** — 调用 LLM 根据对话上下文生成符合语境的替换回复(支持 OpenAI / Ollama / OpenRouter 等兼容接口)
- **安全兜底** — 可清理只有 `event_msg` 的历史 Codex 拒绝记录;AI 返回问号乱码时自动回退到安全默认文本
- **桌面端投影** — 同步清理 `item_completed/AgentMessage` 和 `thread_history`,避免只改 JSONL 后界面仍显示拒绝
- **批量清理** — 处理会话中所有拒绝回复,而非仅最后一条
- **推理内容擦除** — 删除 Reasoning / Thinking block 加密推理内容
- **备份还原** — 清理前自动备份,支持一键还原到任意历史版本
Expand Down
1 change: 1 addition & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ AI coding tools (Codex, Claude Code, OpenCode) frequently refuse security-relate
- **Smart Detection** — Two-level refusal detection (strong phrase full-text match + weak keyword prefix match), low false positive rate
- **AI Rewrite** — Call LLM to generate context-aware replacement responses (supports OpenAI / Ollama / OpenRouter compatible APIs)
- **Safe Fallbacks** — Clean historical Codex refusals that only contain `event_msg`; fall back to a safe default when AI returns question-mark mojibake
- **Desktop projection** — Also update `item_completed/AgentMessage` and `thread_history`, so the desktop app does not keep showing a refusal after the JSONL is cleaned
- **Batch Cleaning** — Process all refusal responses in a session, not just the last one
- **Reasoning Erasure** — Remove encrypted Reasoning / Thinking block content
- **Backup & Restore** — Auto-backup before cleaning, one-click restore to any historical version
Expand Down
2 changes: 1 addition & 1 deletion codex_session_patcher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
clean_session_jsonl,
)

__version__ = "1.5.0"
__version__ = "1.5.1"
__all__ = [
'REFUSAL_KEYWORDS',
'MOCK_RESPONSE',
Expand Down
16 changes: 13 additions & 3 deletions codex_session_patcher/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
MOCK_RESPONSE,
OpenCodeDBAdapter,
)
from .core.patcher import save_session_jsonl
from .core.patcher import publish_cleaned_codex_session, save_session_jsonl
from .core.sqlite_adapter import DEFAULT_OPENCODE_DB

def load_config():
Expand Down Expand Up @@ -423,12 +423,22 @@ def main():
continue

# 创建备份
backup_path = None
if not args.no_backup:
backup_path = create_unique_backup(session.path)
print(f' 备份: {backup_path}')

# 保存修改
save_session_jsonl(cleaned_lines, session.path)
# 保存修改。Codex 桌面端还要同步 thread_history 投影。
if session.format == SessionFormat.CODEX:
publish_cleaned_codex_session(
session.path,
cleaned_lines,
changes,
old_size=os.path.getsize(session.path),
backup_path=backup_path,
)
else:
save_session_jsonl(cleaned_lines, session.path)
print(f' 已保存修改')
total_modified += 1

Expand Down
3 changes: 3 additions & 0 deletions codex_session_patcher/core/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def __init__(self, custom_keywords: Optional[Dict[str, List[str]]] = None):
# 中文
"我无法协助", "我无法帮助", "我不能协助", "我不能帮助",
"我不能做", "我无法做", "我不会帮", "我无法提供",
"不能继续做这件事", "无法继续做这件事", "不会继续做这件事",
"不能继续处理这件事", "无法继续处理这件事",
"违反了我的", "违反我的", "这违反",
"我拒绝", "我必须拒绝",
# 英文
Expand All @@ -45,6 +47,7 @@ def __init__(self, custom_keywords: Optional[Dict[str, List[str]]] = None):
# 中文 - 省略主语的直接拒绝
"不能帮你", "不能协助", "无法帮你", "无法协助你",
"不会帮你", "不会帮助", "不能为你", "无法为你",
"不能继续做这", "无法继续做这", "不能继续处理这", "无法继续处理这",
# 英文
"sorry", "apologize", "i cannot", "i can't",
"i'm unable", "unable to", "not permitted", "not allowed",
Expand Down
157 changes: 110 additions & 47 deletions codex_session_patcher/core/formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,89 +49,152 @@ def remove_thinking_from_message(self, msg: Dict[str, Any]) -> Tuple[Dict[str, A
"""
return msg, 0

def message_item_id(self, msg: Dict[str, Any]) -> str:
"""助手消息在桌面端投影里的 item id。没有稳定 id 时返回空字符串。"""
return ""

def reasoning_item_id(self, msg: Dict[str, Any]) -> str:
"""推理记录的 item id。没有稳定 id 时返回空字符串。"""
return ""


# ─── Codex 策略 ───────────────────────────────────────────────────────────────

class CodexFormatStrategy(FormatStrategy):
"""Codex CLI 格式:response_item + payload 包装"""
"""Codex CLI / 桌面端格式:response_item + payload,以及 item_completed 投影副本。"""

_TEXT_TYPES = ("output_text", "text", "Text")

def _completed_item(self, msg: Dict[str, Any]):
if msg.get("type") != "event_msg":
return None
payload = msg.get("payload") or {}
if payload.get("type") != "item_completed":
return None
item = payload.get("item")
return item if isinstance(item, dict) else None

def message_item_id(self, msg: Dict[str, Any]) -> str:
item = self._completed_item(msg)
if item and item.get("type") == "AgentMessage":
return str(item.get("id") or "")
if msg.get("type") == "response_item":
payload = msg.get("payload") or {}
if payload.get("type") == "message" and payload.get("role") == "assistant":
return str(payload.get("id") or "")
return ""

def reasoning_item_id(self, msg: Dict[str, Any]) -> str:
item = self._completed_item(msg)
if item and item.get("type") == "Reasoning":
return str(item.get("id") or "")
if msg.get("type") == "response_item":
payload = msg.get("payload") or {}
if payload.get("type") == "reasoning":
return str(payload.get("id") or "")
return ""

def _joined_text(self, content) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
texts = []
for item in content:
if isinstance(item, dict) and item.get("type") in self._TEXT_TYPES:
texts.append(item.get("text") or "")
return "\n".join(texts)
return ""

def _replace_text(self, content, new_text: str):
if isinstance(content, list):
replaced = False
for item in content:
if isinstance(item, dict) and item.get("type") in self._TEXT_TYPES:
item["text"] = new_text
replaced = True
if not replaced:
content.append({"type": "Text", "text": new_text})
return content
return [{"type": "output_text", "text": new_text}]

def get_assistant_messages(self, lines):
messages = []
for idx, line in enumerate(lines):
line_type = line.get('type')
payload = line.get('payload', {})
line_type = line.get("type")
payload = line.get("payload", {})

# response_item: role=assistant(主消息结构)
if line_type == 'response_item':
if payload.get('type') == 'message' and payload.get('role') == 'assistant':
if line_type == "response_item":
if payload.get("type") == "message" and payload.get("role") == "assistant":
messages.append((idx, line))

# event_msg: agent_message(assistant 回复的冗余副本,resume 时展示用)
elif line_type == 'event_msg':
pt = payload.get('type')
if pt == 'agent_message' and payload.get('message'):
# event_msg: 旧版冗余副本,以及桌面端 item_completed/AgentMessage
elif line_type == "event_msg":
pt = payload.get("type")
item = self._completed_item(line)
if item and item.get("type") == "AgentMessage" and self.extract_text_content(line):
messages.append((idx, line))
elif pt == "agent_message" and payload.get("message"):
messages.append((idx, line))
elif pt == 'task_complete' and payload.get('last_agent_message'):
elif pt == "task_complete" and payload.get("last_agent_message"):
messages.append((idx, line))

return messages

def get_thinking_items(self, lines):
items = []
for idx, line in enumerate(lines):
if line.get('type') == 'response_item':
payload = line.get('payload', {})
if payload.get('type') == 'reasoning':
if line.get("type") == "response_item":
payload = line.get("payload", {})
if payload.get("type") == "reasoning":
items.append((idx, line))
continue
item = self._completed_item(line)
if item and item.get("type") == "Reasoning":
items.append((idx, line))
return items

def extract_text_content(self, msg):
line_type = msg.get('type')
payload = msg.get('payload', {})
line_type = msg.get("type")
payload = msg.get("payload", {})

item = self._completed_item(msg)
if item and item.get("type") == "AgentMessage":
return self._joined_text(item.get("content"))

# event_msg/agent_message
if line_type == 'event_msg':
pt = payload.get('type')
if pt == 'agent_message':
return payload.get('message', '')
if pt == 'task_complete':
return payload.get('last_agent_message', '')
return ''
if line_type == "event_msg":
pt = payload.get("type")
if pt == "agent_message":
return payload.get("message", "")
if pt == "task_complete":
return payload.get("last_agent_message", "")
return ""

# response_item/assistant
content = payload.get('content', [])
if isinstance(content, str):
return content
if isinstance(content, list):
texts = []
for item in content:
if isinstance(item, dict) and item.get('type') == 'output_text':
texts.append(item.get('text', ''))
return '\n'.join(texts)
return ''
return self._joined_text(payload.get("content", []))

def update_text_content(self, msg, new_text):
updated = copy.deepcopy(msg)
line_type = updated.get('type')
payload = updated.get('payload', {})
line_type = updated.get("type")
payload = updated.get("payload", {})

item = self._completed_item(updated)
if item and item.get("type") == "AgentMessage":
item["content"] = self._replace_text(item.get("content"), new_text)
return updated

# event_msg/agent_message 和 event_msg/task_complete
if line_type == 'event_msg':
pt = payload.get('type')
if pt == 'agent_message':
payload['message'] = new_text
elif pt == 'task_complete':
payload['last_agent_message'] = new_text
if line_type == "event_msg":
pt = payload.get("type")
if pt == "agent_message":
payload["message"] = new_text
elif pt == "task_complete":
payload["last_agent_message"] = new_text
return updated

# response_item/assistant
content = payload.get('content', [])
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get('type') == 'output_text':
item['text'] = new_text
else:
payload['content'] = [{'type': 'output_text', 'text': new_text}]
payload["content"] = self._replace_text(payload.get("content", []), new_text)
return updated


Expand Down
Loading