-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathhook.ts
More file actions
173 lines (144 loc) · 4.84 KB
/
hook.ts
File metadata and controls
173 lines (144 loc) · 4.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
import { join } from "path";
import { tmpdir } from "os";
import { mkdir, unlink } from "fs/promises";
import { parseTranscript } from "./transcript.ts";
import { compact } from "./morph.ts";
interface PreCompactInput {
session_id: string;
transcript_path: string;
trigger: string;
custom_instructions: string;
}
interface SessionStartInput {
session_id: string;
transcript_path: string;
source: "startup" | "resume" | "compact";
}
interface StateData {
summary: string;
warn: boolean;
error?: string;
stats?: {
messageCount: number;
inputChars: number;
outputChars: number;
durationMs: number;
};
}
const STATE_DIR = join(tmpdir(), "compact-hook");
function stateFile(sessionID: string): string {
return join(STATE_DIR, sessionID);
}
async function readStdin<T>(): Promise<T> {
return JSON.parse(await Bun.stdin.text()) as T;
}
const COMPACT_INSTRUCTIONS = "morph";
function log(msg: string): void {
process.stderr.write(`[morph-compact] ${msg}\n`);
}
export async function hookPreCompact(): Promise<void> {
const input = await readStdin<PreCompactInput>();
if (!input.session_id) throw new Error("no session_id in hook input");
if (!input.transcript_path)
throw new Error("no transcript_path in hook input");
log(
`PreCompact triggered: trigger=${input.trigger} session=${input.session_id}`,
);
if (!(await Bun.file(input.transcript_path).exists())) {
throw new Error(`transcript not found: ${input.transcript_path}`);
}
await mkdir(STATE_DIR, { recursive: true });
const sf = stateFile(input.session_id);
const existing = Bun.file(sf);
if (await existing.exists()) {
const prev = (await existing.json()) as StateData;
if (prev.summary) {
log("PreCompact: cached summary found, skipping API call");
return;
}
}
try {
const messages = await parseTranscript(input.transcript_path);
const inputChars = messages.reduce((n, m) => n + m.content.length, 0);
log(
`PreCompact: parsed ${messages.length} messages (${inputChars} chars), calling Morph API...`,
);
const start = performance.now();
const summary = await compact(messages);
const durationMs = Math.round(performance.now() - start);
const ratio =
inputChars > 0 ? ((summary.length / inputChars) * 100).toFixed(1) : "N/A";
log(
`PreCompact: compaction complete in ${durationMs}ms — ${inputChars} → ${summary.length} chars (${ratio}% ratio)`,
);
const state: StateData = {
summary,
warn: input.trigger === "manual" && !input.custom_instructions,
stats: {
messageCount: messages.length,
inputChars,
outputChars: summary.length,
durationMs,
},
};
await Bun.write(sf, JSON.stringify(state));
} catch (e) {
log(`PreCompact: error — ${(e as Error).message}`);
const state: StateData = {
summary: "",
warn: false,
error: (e as Error).message,
};
await Bun.write(sf, JSON.stringify(state));
}
}
export async function hookSessionStart(): Promise<void> {
const input = await readStdin<SessionStartInput>();
if (!input.session_id) throw new Error("no session_id in hook input");
log(`SessionStart: source=${input.source} session=${input.session_id}`);
const sf = stateFile(input.session_id);
const file = Bun.file(sf);
if (!(await file.exists())) {
log("SessionStart: no state file, nothing to inject");
return;
}
const state = (await file.json()) as StateData;
log(
`SessionStart: state=${JSON.stringify({ error: state.error, warn: state.warn, summaryLen: state.summary?.length ?? 0, stats: state.stats })}`,
);
if (state.error) {
log(`SessionStart: injecting error — ${state.error}`);
process.stdout.write(
"ERROR: Morph compaction failed: " +
state.error +
"\n" +
"Inform the user about this error. Context from the previous conversation was NOT preserved.",
);
await unlink(sf).catch(() => {});
return;
}
if (!state.summary) {
log("SessionStart: state file has no summary, skipping");
return;
}
let data = state.summary;
if (state.warn) {
data +=
"\n\n---\n" +
"WARNING: `/compact` was run without the `morph` instruction, " +
"so Claude performed its own slow summarization. " +
"Inform the user that for faster compaction they should run: `/compact morph`";
}
if (state.stats) {
const { messageCount, inputChars, outputChars, durationMs } = state.stats;
const ratio =
inputChars > 0 ? ((outputChars / inputChars) * 100).toFixed(1) : "N/A";
log(
`SessionStart: injecting summary — ${messageCount} messages, ${inputChars} → ${outputChars} chars (${ratio}%), took ${durationMs}ms`,
);
} else {
log(`SessionStart: injecting summary (${data.length} chars)`);
}
process.stdout.write(data);
await unlink(sf).catch(() => {});
}