diff --git a/hindsight-integrations/claude-code/scripts/lib/content.py b/hindsight-integrations/claude-code/scripts/lib/content.py index 435bdcea9b..ca94325dc7 100644 --- a/hindsight-integrations/claude-code/scripts/lib/content.py +++ b/hindsight-integrations/claude-code/scripts/lib/content.py @@ -48,6 +48,28 @@ def strip_memory_tags(content: str) -> str: return content +def strip_harness_blocks(content: str) -> str: + """Remove and blocks. + + Claude Code injects these into user turns. They are harness output rather + than conversation, so retaining them teaches Hindsight about hook and tool + plumbing instead of the user's work. + """ + content = re.sub(r"[\s\S]*?", "", content) + content = re.sub(r"[\s\S]*?", "", content) + return content + + +def is_synthetic_user_message(content: str) -> bool: + """Return True for skill bodies Claude Code persists as user chat. + + Claude Code records an invoked skill's full SKILL.md as a normal user + message. Retaining it stores documentation as if the user had written it, + and can create very large retain payloads. + """ + return content.lstrip().startswith("Base directory for this skill: ") + + # --------------------------------------------------------------------------- # Recall: query composition and truncation # --------------------------------------------------------------------------- @@ -244,7 +266,7 @@ def _extract_message_blocks(content, role: str = "") -> list: - Channel message tool_use blocks get their text extracted inline. """ if isinstance(content, str): - cleaned = strip_channel_envelope(strip_memory_tags(content)).strip() + cleaned = strip_harness_blocks(strip_channel_envelope(strip_memory_tags(content))).strip() return [{"type": "text", "text": cleaned}] if cleaned else [] if not isinstance(content, list): @@ -257,7 +279,7 @@ def _extract_message_blocks(content, role: str = "") -> list: block_type = block.get("type", "") if block_type == "text": - text = strip_channel_envelope(strip_memory_tags(block.get("text", ""))).strip() + text = strip_harness_blocks(strip_channel_envelope(strip_memory_tags(block.get("text", "")))).strip() if text: blocks.append({"type": "text", "text": text}) @@ -342,6 +364,15 @@ def prepare_retention_transcript( allowed_roles = set(retain_roles or ["user", "assistant"]) + target_messages = [ + msg + for msg in target_messages + if not ( + msg.get("role") == "user" + and is_synthetic_user_message(_extract_text_content(msg.get("content", ""), role="user")) + ) + ] + if include_tool_calls: return _prepare_json_transcript(target_messages, allowed_roles) return _prepare_text_transcript(target_messages, allowed_roles) @@ -384,7 +415,7 @@ def _prepare_text_transcript(messages: list, allowed_roles: set) -> tuple: content = _extract_text_content(msg.get("content", ""), role=role) content = strip_channel_envelope(content) - content = strip_memory_tags(content).strip() + content = strip_harness_blocks(strip_memory_tags(content)).strip() if not content: continue diff --git a/hindsight-integrations/claude-code/tests/test_content.py b/hindsight-integrations/claude-code/tests/test_content.py index 8aaaeae29a..78730e397f 100644 --- a/hindsight-integrations/claude-code/tests/test_content.py +++ b/hindsight-integrations/claude-code/tests/test_content.py @@ -10,9 +10,11 @@ compose_recall_query, format_current_time, format_memories, + is_synthetic_user_message, prepare_retention_transcript, slice_last_turns_by_user_boundary, strip_channel_envelope, + strip_harness_blocks, strip_memory_tags, truncate_recall_query, ) @@ -67,6 +69,54 @@ def test_strips_multiline_block(self): assert strip_memory_tags(raw).strip() == "" +# --------------------------------------------------------------------------- +# strip_harness_blocks +# --------------------------------------------------------------------------- + + +class TestStripHarnessBlocks: + def test_strips_system_reminder_block(self): + raw = "before\nplugin chatter\nafter" + result = strip_harness_blocks(raw) + assert "plugin chatter" not in result + assert "before" in result + assert "after" in result + + def test_strips_task_notification_block(self): + raw = "text background job done text" + result = strip_harness_blocks(raw) + assert "background job done" not in result + + def test_strips_multiline_block(self): + raw = "\nline1\nline2\n" + assert strip_harness_blocks(raw).strip() == "" + + def test_passthrough_clean_text(self): + raw = "no harness blocks here" + assert strip_harness_blocks(raw) == raw + + def test_keeps_unpaired_mention(self): + raw = "the tag is injected by the harness" + assert strip_harness_blocks(raw) == raw + + +# --------------------------------------------------------------------------- +# is_synthetic_user_message +# --------------------------------------------------------------------------- + + +class TestIsSyntheticUserMessage: + def test_detects_skill_body(self): + raw = "Base directory for this skill: /tmp/skills/foo\n\n# Foo\n\nDocs." + assert is_synthetic_user_message(raw) is True + + def test_detects_leading_whitespace(self): + assert is_synthetic_user_message("\n Base directory for this skill: /tmp/x") is True + + def test_rejects_ordinary_message(self): + assert is_synthetic_user_message("what is the base directory for this skill?") is False + + # --------------------------------------------------------------------------- # slice_last_turns_by_user_boundary # --------------------------------------------------------------------------- @@ -323,6 +373,36 @@ def test_strips_memory_tags(self): assert "leaked" not in transcript assert "actual question" in transcript + def test_strips_harness_blocks(self): + msgs = _msgs(("user", "plugin chatter actual question")) + transcript, _ = prepare_retention_transcript(msgs, retain_full_window=True) + assert "plugin chatter" not in transcript + assert "actual question" in transcript + + def test_skips_skill_body_user_message(self): + msgs = _msgs( + ("user", "Base directory for this skill: /tmp/skills/foo\n\n# Foo\n\nDocs."), + ("user", "real question"), + ("assistant", "real reply"), + ) + transcript, count = prepare_retention_transcript(msgs, retain_full_window=True) + assert "Docs." not in transcript + assert "real question" in transcript + assert count == 2 + + def test_skips_skill_body_in_json_mode(self): + msgs = _msgs( + ("user", "Base directory for this skill: /tmp/skills/foo\n\n# Foo\n\nDocs."), + ("user", "real question"), + ) + transcript, count = prepare_retention_transcript(msgs, retain_full_window=True, include_tool_calls=True) + assert "Docs." not in transcript + assert count == 1 + + def test_returns_none_when_only_skill_body(self): + msgs = _msgs(("user", "Base directory for this skill: /tmp/skills/foo\n\n# Foo\n\nDocs.")) + assert prepare_retention_transcript(msgs, retain_full_window=True) == (None, 0) + def test_filters_by_retain_roles(self): msgs = _msgs(("user", "user msg"), ("assistant", "assistant msg")) transcript, _ = prepare_retention_transcript(msgs, retain_roles=["user"], retain_full_window=True)