Skip to content
Open
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
103 changes: 103 additions & 0 deletions src/ai_rules/config/claude/hooks/distill_briefing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Distill Sub-Agent Briefing

You are a specialized context distillation agent. Your job is to produce a high-fidelity structured summary of a coding session transcript.

You operate in a FRESH context — you have never seen this conversation before. The transcript below is your only source of truth.

## Date and Project

- Date: {{DATE}}
- Project: {{PROJECT}}

## Verbatim Preservation Rules (CRITICAL)

These rules override all other summarization instincts. Violating them defeats the purpose of distillation.

**COPY EXACTLY — never paraphrase:**
- File paths: `/src/ai_rules/config/claude/settings.json` not "the settings file"
- Function/class/variable names: `extract_transcript()` not "the extraction function"
- Error codes and messages: `ImportError: No module named 'distill_core'` not "an import error"
- Branch names: `feature/distill-skill` not "the feature branch"
- CLI flags and commands: `claude -p --model sonnet` not "the Claude CLI"
- Config keys: `autoCompactEnabled` not "the auto-compact setting"

**COPY VERBATIM — user instructions are sacred:**
- Section 7 (User Instructions and Constraints) is the most critical section
- Every "don't do X", "always Y", "use Z approach" must be preserved word-for-word
- Every correction ("no, not that — do this instead") must be captured
- Do not soften, reinterpret, or paraphrase user constraints

**Recent exchanges get MORE detail, not less:**
- The last 3-5 conversational turns should be summarized with higher fidelity
- These represent the most immediately actionable context

## Anti-Patterns (DO NOT)

- "The user and assistant discussed X" → WRONG. State WHAT was decided, not that a discussion happened.
- "Several files were modified" → WRONG. List WHICH files with their full paths.
- "Various approaches were considered" → WRONG. List the specific approaches and their outcomes.
- "The configuration was updated" → WRONG. State which config file, which keys, what values.
- Abstractive paraphrase of technical terms → WRONG. Use the exact terms from the transcript.
- Omitting failed approaches → WRONG. Dead ends prevent re-exploration.

## Prior Summary

{{PRIOR_SUMMARY}}

If a prior summary is provided above, this is an INCREMENTAL distillation:
- Extend the prior summary rather than starting from scratch
- Preserve ALL verbatim content from the prior summary
- Add new information from the transcript that occurred after the prior distillation
- If the prior summary conflicts with the transcript, trust the transcript
- Update section 2 (Current Work State) and section 9 (Next Step) to reflect the latest state

If "[None — first distillation]" appears above, produce a complete summary from scratch.

## Output Format

Produce the summary following this exact structure. Do not add or remove sections.

---

## 1. Primary Request and Intent

[Concise summary of what the user is trying to accomplish and why]

## 2. Current Work State

[Exact current state with verbatim identifiers — branch, files, phase, active work]

## 3. Key Technical Decisions

[Each decision: what was decided, why, what was rejected]

## 4. Files and Code

[Every file touched — verbatim paths, role, what was done]

## 5. Errors and Fixes

[Every error — verbatim message, cause, resolution]

## 6. Problem Solving Progress

[Approaches tried, outcomes, direction, dead ends]

## 7. User Instructions and Constraints

[EVERY constraint verbatim — this is the most critical section]

## 8. Pending Tasks

1. [Task with actionable detail]
2. [Task with actionable detail]

## 9. Next Step

[Single most important action with enough context to execute cold]

---

## Transcript

{{TRANSCRIPT}}
180 changes: 180 additions & 0 deletions src/ai_rules/config/claude/hooks/distill_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Shared distillation logic for PreCompact and SessionStart hooks.

Extracts conversation transcripts from JSONL, applies observation masking,
runs a fresh-context summarization subprocess, and persists artifacts.
"""

import glob
import json
import os
import shutil
import subprocess

from datetime import datetime
from pathlib import Path


def get_project_slug(cwd: str) -> str:
return cwd.replace("/", "-")


def get_jsonl_path(cwd: str) -> str | None:
home = os.path.expanduser("~")
slug = get_project_slug(cwd)
proj_dir = f"{home}/.claude/projects/{slug}"
files = sorted(glob.glob(f"{proj_dir}/*.jsonl"), key=os.path.getmtime, reverse=True)
return files[0] if files else None


def get_summary_path(cwd: str) -> Path:
home = os.path.expanduser("~")
slug = get_project_slug(cwd)
return Path(home) / ".claude" / "distill-summaries" / f"{slug}.md"


def get_backup_path(cwd: str, date: str | None = None) -> Path:
home = os.path.expanduser("~")
slug = get_project_slug(cwd)
if date is None:
date = datetime.now().strftime("%Y-%m-%d")
return Path(home) / ".claude" / "distill-backups" / f"{date}-{slug}.txt"


def read_prior_summary(cwd: str) -> str | None:
path = get_summary_path(cwd)
if path.exists():
return path.read_text()
return None


def extract_transcript(jsonl_path: str, max_chars: int = 120_000) -> str:
lines: list[str] = []

with open(jsonl_path) as f:
for raw_line in f:
raw_line = raw_line.strip()
if not raw_line:
continue
try:
record = json.loads(raw_line)
except json.JSONDecodeError:
continue

msg_type = record.get("type", "")
if msg_type == "summary":
text = record.get("summary", "")
if text:
lines.append(f"[PRIOR COMPACTION SUMMARY]\n{text}\n")
continue

message = record.get("message", {})
if not isinstance(message, dict):
continue

role = message.get("role", "")
if role not in ("user", "assistant"):
continue

content = message.get("content", "")
if isinstance(content, str):
lines.append(f"[{role.upper()}]\n{content}\n")
elif isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
parts.append(_process_content_block(block))
if parts:
lines.append(
f"[{role.upper()}]\n" + "\n".join(p for p in parts if p) + "\n"
)

transcript = "\n".join(lines)

if len(transcript) > max_chars:
transcript = transcript[-max_chars:]
first_newline = transcript.find("\n")
if first_newline > 0:
transcript = transcript[first_newline + 1 :]
transcript = "[...transcript truncated from oldest end...]\n\n" + transcript

return transcript


def _process_content_block(block: dict[str, object]) -> str:
btype = block.get("type", "")

if btype == "text":
return str(block.get("text", ""))

if btype == "tool_use":
name = block.get("name", "unknown")
inp = block.get("input", {})
inp_str = json.dumps(inp) if isinstance(inp, dict) else str(inp)
if len(inp_str) > 500:
inp_str = inp_str[:500] + "..."
return f"[Tool Call: {name}({inp_str})]"

if btype == "tool_result":
tool_id = block.get("tool_use_id", "unknown")
result_content = block.get("content", "")
if isinstance(result_content, str):
char_count = len(result_content)
elif isinstance(result_content, list):
char_count = sum(len(json.dumps(r)) for r in result_content)
else:
char_count = len(str(result_content))
return f"[Tool Result: {tool_id} -- {char_count} chars, masked]"

return str(block.get("text", ""))


def run_distill_subprocess(
transcript: str,
prior_summary: str | None,
briefing_template: str,
cwd: str | None = None,
timeout: int = 120,
) -> str | None:
prior = prior_summary if prior_summary else "[None — first distillation]"
date = datetime.now().strftime("%Y-%m-%d")
if cwd is None:
cwd = os.getcwd()

briefing = briefing_template
briefing = briefing.replace("{{DATE}}", date)
briefing = briefing.replace("{{PROJECT}}", cwd)
briefing = briefing.replace("{{PRIOR_SUMMARY}}", prior)
briefing = briefing.replace("{{TRANSCRIPT}}", transcript)

try:
result = subprocess.run(
["claude", "-p", "--model", "sonnet"],
input=briefing,
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None


def save_artifacts(cwd: str, summary: str, transcript: str) -> tuple[Path, Path]:
summary_path = get_summary_path(cwd)
backup_path = get_backup_path(cwd)

summary_path.parent.mkdir(parents=True, exist_ok=True)
backup_path.parent.mkdir(parents=True, exist_ok=True)

prev_path = summary_path.with_name(f"{summary_path.stem}-prev{summary_path.suffix}")
if summary_path.exists():
shutil.move(str(summary_path), str(prev_path))

summary_path.write_text(summary)
backup_path.write_text(transcript)

return summary_path, backup_path
41 changes: 41 additions & 0 deletions src/ai_rules/config/claude/hooks/post_compact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""SessionStart compact hook: re-injects distill summary after CC compaction.

Safety net for the PreCompact hook. If the PreCompact stdout -> compaction
model channel fails (undocumented behavior), this hook ensures the distill
summary still reaches the post-compaction context as a system message.

Simple: read summary file, print to stdout. No subprocess, no heavy logic.
Always exits 0.
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))

try:
import distill_core # type: ignore[import-not-found]
except ImportError:
sys.exit(0)


def main() -> None:
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
return

cwd = hook_input.get("cwd", os.getcwd())

summary = distill_core.read_prior_summary(cwd)
if summary:
print(summary)


if __name__ == "__main__":
try:
main()
except Exception:
sys.exit(0)
Loading