Skip to content

Commit 69dd625

Browse files
committed
fix(claude-code): normalize bash blocks and compaction summaries, redact jieli keys
- Render real `!command` <bash-input>/<bash-stdout>/<bash-stderr> messages as a single terminal-style console block; merge an output message into the input block emitted just before it. Prose that merely quotes the tags is left verbatim. - Replace long auto-compaction summaries (isCompactSummary) with a short placeholder so they stop dominating the synced payload while keeping the timeline marker. - Add a redaction rule for jieli_* API keys so command output that echoes a key is masked before upload.
1 parent 2496dcb commit 69dd625

3 files changed

Lines changed: 363 additions & 23 deletions

File tree

plugins/claude-code/scripts/redact.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ def sensitive_key_id(raw_key: str, prefix: str = "") -> str:
163163
"cloudflare-token", re.compile(r"\bcfut_[A-Za-z0-9_-]{20,}\b"), ("cfut_",)
164164
),
165165
RedactionRule("e2b-token", re.compile(r"\be2b_[A-Za-z0-9_-]{20,}\b"), ("e2b_",)),
166+
RedactionRule(
167+
"jieli-api-key", re.compile(r"\bjieli_[A-Za-z0-9_-]{20,}\b"), ("jieli_",)
168+
),
166169
RedactionRule(
167170
"google-api-key",
168171
re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),

plugins/claude-code/scripts/sync.py

Lines changed: 118 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@
3939
IMAGE_PLACEHOLDER_RE = re.compile(r"\[Image:\s*source:\s*([^\]]+)\]")
4040
IMAGE_LABEL_RE = re.compile(r"\[Image\s+#\d+\]")
4141
SUPPORTED_IMAGE_MEDIA_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
42+
COMPACTION_PLACEHOLDER = (
43+
"[Context compacted — earlier conversation summarized to continue past the context window]"
44+
)
45+
BASH_INPUT_RE = re.compile(r"<bash-input>(.*?)</bash-input>", re.DOTALL)
46+
BASH_STDOUT_RE = re.compile(r"<bash-stdout>(.*?)</bash-stdout>", re.DOTALL)
47+
BASH_STDERR_RE = re.compile(r"<bash-stderr>(.*?)</bash-stderr>", re.DOTALL)
48+
BASH_NO_OUTPUT_MARKERS = {"", "(Bash completed with no output)"}
4249

4350
ImageUploader = Callable[[Path], str]
4451

@@ -119,6 +126,8 @@ def parse_transcript(path: Path, fallback_session_id: str | None = None, image_u
119126
model = ""
120127
created_ms = 0
121128
updated_ms = 0
129+
pending_bash_index: int | None = None
130+
pending_bash_command = ""
122131
with path.open("r", encoding="utf-8") as handle:
123132
for line in handle:
124133
if not line.strip():
@@ -132,28 +141,63 @@ def parse_transcript(path: Path, fallback_session_id: str | None = None, image_u
132141
message = entry.get("message")
133142
if not isinstance(message, dict):
134143
continue
135-
content = normalize_content(message.get("content"), image_uploader=image_uploader)
136-
if content is None:
137-
continue
138-
role = normalized_role(message.get("role") or entry.get("type"), content)
139-
content = normalize_local_command_message(role, content)
140-
if content is None or is_loaded_skill_body_message(role, content):
141-
continue
142-
source_message_id = message.get("id") or ""
143-
item = {
144-
"role": role,
145-
"content": content,
146-
"message_id": entry.get("uuid") or source_message_id or message.get("message_id") or "",
147-
}
148-
usage = message.get("usage")
149-
if isinstance(usage, dict):
150-
item["usage"] = redact_json(usage)
151-
protocol_id = message.get("protocolMessageID") or message.get("protocol_message_id")
152-
if protocol_id:
153-
item["protocol_message_id"] = protocol_id
154-
if is_duplicate_unavailable_image_message(messages, item):
155-
continue
156-
append_transcript_message(messages, merge_sources, item, source_message_id)
144+
145+
if entry.get("isCompactSummary"):
146+
# Replace the (very long) auto-compaction summary with a short
147+
# placeholder: keep the timeline marker without bloating the payload.
148+
content = COMPACTION_PLACEHOLDER
149+
role = "user"
150+
else:
151+
content = normalize_content(message.get("content"), image_uploader=image_uploader)
152+
if content is None:
153+
continue
154+
role = normalized_role(message.get("role") or entry.get("type"), content)
155+
content = normalize_local_command_message(role, content)
156+
if content is None or is_loaded_skill_body_message(role, content):
157+
continue
158+
159+
bash = parse_bash_block(content) if role == "user" and isinstance(content, str) else None
160+
if (
161+
bash is not None
162+
and bash["kind"] == "output"
163+
and pending_bash_index is not None
164+
and pending_bash_index == len(messages) - 1
165+
):
166+
# Fold a `!command` stdout/stderr message into the bash block
167+
# emitted just before it, rendering both as one terminal view.
168+
messages[pending_bash_index]["content"] = render_bash_terminal(
169+
pending_bash_command, bash["stdout"], bash["stderr"]
170+
)
171+
pending_bash_index = None
172+
else:
173+
if bash is not None:
174+
if bash["kind"] == "input":
175+
content = render_bash_terminal(bash["command"], note_empty=False)
176+
elif bash["kind"] == "input_output":
177+
content = render_bash_terminal(bash["command"], bash["stdout"], bash["stderr"])
178+
else:
179+
content = render_bash_terminal(None, bash["stdout"], bash["stderr"])
180+
source_message_id = message.get("id") or ""
181+
item = {
182+
"role": role,
183+
"content": content,
184+
"message_id": entry.get("uuid") or source_message_id or message.get("message_id") or "",
185+
}
186+
usage = message.get("usage")
187+
if isinstance(usage, dict):
188+
item["usage"] = redact_json(usage)
189+
protocol_id = message.get("protocolMessageID") or message.get("protocol_message_id")
190+
if protocol_id:
191+
item["protocol_message_id"] = protocol_id
192+
if is_duplicate_unavailable_image_message(messages, item):
193+
continue
194+
append_transcript_message(messages, merge_sources, item, source_message_id)
195+
if bash is not None and bash["kind"] == "input":
196+
pending_bash_index = len(messages) - 1
197+
pending_bash_command = bash["command"]
198+
else:
199+
pending_bash_index = None
200+
157201
session_id = session_id or entry.get("sessionId") or entry.get("session_id") or ""
158202
cwd = cwd or entry.get("cwd") or ""
159203
branch = branch or entry.get("gitBranch") or entry.get("git_branch") or ""
@@ -265,6 +309,57 @@ def normalize_local_command_message(role: str, content: Any) -> Any | None:
265309
return content
266310

267311

312+
def parse_bash_block(text: str) -> dict[str, Any] | None:
313+
# Only treat messages that *start* with a bash tag as real `!command`
314+
# injections. Prose that merely quotes a <bash-input> tag is left untouched.
315+
stripped = text.strip()
316+
starts_input = stripped.startswith("<bash-input>")
317+
starts_output = stripped.startswith("<bash-stdout>") or stripped.startswith("<bash-stderr>")
318+
if not starts_input and not starts_output:
319+
return None
320+
has_output = "<bash-stdout>" in stripped or "<bash-stderr>" in stripped
321+
if starts_input:
322+
command = first_tag_capture(BASH_INPUT_RE, stripped)
323+
if has_output:
324+
return {
325+
"kind": "input_output",
326+
"command": command,
327+
"stdout": first_tag_capture(BASH_STDOUT_RE, stripped),
328+
"stderr": first_tag_capture(BASH_STDERR_RE, stripped),
329+
}
330+
return {"kind": "input", "command": command}
331+
return {
332+
"kind": "output",
333+
"stdout": first_tag_capture(BASH_STDOUT_RE, stripped),
334+
"stderr": first_tag_capture(BASH_STDERR_RE, stripped),
335+
}
336+
337+
338+
def first_tag_capture(pattern: re.Pattern[str], text: str) -> str:
339+
match = pattern.search(text)
340+
return match.group(1) if match else ""
341+
342+
343+
def render_bash_terminal(command: str | None, stdout: str = "", stderr: str = "", note_empty: bool = True) -> str:
344+
out = stdout.strip()
345+
err = stderr.strip()
346+
if out in BASH_NO_OUTPUT_MARKERS:
347+
out = ""
348+
if err in BASH_NO_OUTPUT_MARKERS:
349+
err = ""
350+
lines: list[str] = []
351+
if command is not None:
352+
lines.append(f"$ {command.strip()}")
353+
if out:
354+
lines.append(out)
355+
if err:
356+
lines.append("# [stderr]")
357+
lines.append(err)
358+
if not out and not err and command is not None and note_empty:
359+
lines.append("# (no output)")
360+
return "```console\n" + "\n".join(lines) + "\n```"
361+
362+
268363
def tag_text(text: str, tag_name: str) -> str:
269364
match = re.search(rf"<{tag_name}>\s*(.*?)\s*</{tag_name}>", text, re.DOTALL)
270365
if not match:
@@ -445,7 +540,7 @@ def title_from_messages(messages: list[dict[str, Any]]) -> str:
445540
if message.get("role") != "user":
446541
continue
447542
content = message.get("content")
448-
if isinstance(content, str) and content.strip():
543+
if isinstance(content, str) and content.strip() and content != COMPACTION_PLACEHOLDER:
449544
return content.strip()[:80]
450545
return "Claude Code session"
451546

0 commit comments

Comments
 (0)