Files
gh-nathanvale-side-quest-ma…/hooks/session-summary.ts
2025-11-30 08:42:05 +08:00

159 lines
4.0 KiB
TypeScript

#!/usr/bin/env bun
/**
* Session Summary Hook
*
* PreCompact hook that saves a session summary before context compaction.
* Helps maintain continuity across context windows.
*/
import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { PreCompactHookInput } from "@anthropic-ai/claude-agent-sdk";
interface SessionSummary {
timestamp: string;
branch: string;
trigger: "manual" | "auto";
sessionCommits: string[];
uncommittedChanges: {
staged: string;
modified: string;
};
}
async function exec(
command: string,
cwd: string,
): Promise<{ stdout: string; exitCode: number }> {
const proc = Bun.spawn(["sh", "-c", command], {
cwd,
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
return { stdout: stdout.trim(), exitCode };
}
async function isGitRepo(cwd: string): Promise<boolean> {
const { exitCode } = await exec("git rev-parse --git-dir", cwd);
return exitCode === 0;
}
async function getGitRoot(cwd: string): Promise<string | null> {
const { stdout, exitCode } = await exec("git rev-parse --show-toplevel", cwd);
return exitCode === 0 ? stdout : null;
}
async function getSessionSummary(
cwd: string,
trigger: "manual" | "auto",
): Promise<SessionSummary | null> {
if (!(await isGitRepo(cwd))) {
return null;
}
// Get branch
const { stdout: branch } = await exec(
"git branch --show-current 2>/dev/null || echo '(detached)'",
cwd,
);
// Get commits from the last hour (approximate session length)
const { stdout: commitsOut } = await exec(
'git log --oneline --since="1 hour ago" 2>/dev/null | head -10',
cwd,
);
const sessionCommits = commitsOut
.split("\n")
.filter((line) => line.trim() !== "");
// Get staged changes summary
const { stdout: stagedStat } = await exec(
"git diff --cached --stat 2>/dev/null | tail -1",
cwd,
);
// Get modified changes summary
const { stdout: modifiedStat } = await exec(
"git diff --stat 2>/dev/null | tail -1",
cwd,
);
return {
timestamp: new Date().toISOString(),
branch: branch || "(detached)",
trigger,
sessionCommits,
uncommittedChanges: {
staged: stagedStat || "none",
modified: modifiedStat || "none",
},
};
}
function formatSummary(summary: SessionSummary): string {
let output = "# Claude Session Summary\n";
output += `# Generated: ${summary.timestamp}\n`;
output += `# Branch: ${summary.branch}\n`;
output += `# Trigger: ${summary.trigger}\n\n`;
output += "## Session Activity\n\n";
if (summary.sessionCommits.length > 0) {
output += "### Commits this session:\n";
summary.sessionCommits.forEach((commit) => {
output += `- ${commit}\n`;
});
output += "\n";
}
if (
summary.uncommittedChanges.staged !== "none" ||
summary.uncommittedChanges.modified !== "none"
) {
output += "### Uncommitted changes:\n";
if (summary.uncommittedChanges.staged !== "none") {
output += `Staged: ${summary.uncommittedChanges.staged}\n`;
}
if (summary.uncommittedChanges.modified !== "none") {
output += `Modified: ${summary.uncommittedChanges.modified}\n`;
}
output += "\n";
}
return output;
}
// Main execution
const input = (await Bun.stdin.json()) as PreCompactHookInput;
const { cwd, trigger } = input;
const gitRoot = await getGitRoot(cwd);
if (gitRoot) {
const summary = await getSessionSummary(cwd, trigger);
if (summary) {
// Write to ~/.claude/session-summaries/ to avoid polluting user repos
const claudeDir = join(homedir(), ".claude", "session-summaries");
if (!existsSync(claudeDir)) {
mkdirSync(claudeDir, { recursive: true });
}
// Use repo name as filename to keep summaries separate per project
const repoName = gitRoot.split("/").pop() || "unknown";
const summaryPath = join(claudeDir, `${repoName}.md`);
const content = formatSummary(summary);
await Bun.write(summaryPath, content);
console.log(`Session summary saved to ${summaryPath}`);
}
}
process.exit(0);