hydramcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +111 -0
- package/dist/orchestrator/cache.d.ts +44 -0
- package/dist/orchestrator/cache.js +80 -0
- package/dist/orchestrator/circuit-breaker.d.ts +34 -0
- package/dist/orchestrator/circuit-breaker.js +90 -0
- package/dist/orchestrator/config.d.ts +15 -0
- package/dist/orchestrator/config.js +50 -0
- package/dist/orchestrator/index.d.ts +37 -0
- package/dist/orchestrator/index.js +143 -0
- package/dist/orchestrator/metrics.d.ts +40 -0
- package/dist/orchestrator/metrics.js +77 -0
- package/dist/providers/anthropic.d.ts +20 -0
- package/dist/providers/anthropic.js +101 -0
- package/dist/providers/google.d.ts +18 -0
- package/dist/providers/google.js +123 -0
- package/dist/providers/multi-provider.d.ts +23 -0
- package/dist/providers/multi-provider.js +71 -0
- package/dist/providers/ollama.d.ts +26 -0
- package/dist/providers/ollama.js +87 -0
- package/dist/providers/openai.d.ts +17 -0
- package/dist/providers/openai.js +91 -0
- package/dist/providers/provider.d.ts +40 -0
- package/dist/providers/provider.js +9 -0
- package/dist/providers/subscription.d.ts +27 -0
- package/dist/providers/subscription.js +193 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +238 -0
- package/dist/setup.d.ts +14 -0
- package/dist/setup.js +252 -0
- package/dist/tools/analyze-file.d.ts +40 -0
- package/dist/tools/analyze-file.js +227 -0
- package/dist/tools/ask-model.d.ts +49 -0
- package/dist/tools/ask-model.js +122 -0
- package/dist/tools/compare-models.d.ts +40 -0
- package/dist/tools/compare-models.js +104 -0
- package/dist/tools/consensus.d.ts +50 -0
- package/dist/tools/consensus.js +267 -0
- package/dist/tools/session-recap.d.ts +38 -0
- package/dist/tools/session-recap.js +341 -0
- package/dist/tools/smart-read.d.ts +45 -0
- package/dist/tools/smart-read.js +259 -0
- package/dist/tools/synthesize.d.ts +44 -0
- package/dist/tools/synthesize.js +182 -0
- package/dist/utils/compress.d.ts +27 -0
- package/dist/utils/compress.js +132 -0
- package/dist/utils/env.d.ts +11 -0
- package/dist/utils/env.js +44 -0
- package/dist/utils/logger.d.ts +14 -0
- package/dist/utils/logger.js +27 -0
- package/dist/utils/model-selection.d.ts +23 -0
- package/dist/utils/model-selection.js +54 -0
- package/dist/utils/session-reader.d.ts +67 -0
- package/dist/utils/session-reader.js +383 -0
- package/package.json +56 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consensus — Ask multiple models, aggregate into a single answer.
|
|
3
|
+
*
|
|
4
|
+
* This is the "I need confidence" tool. Instead of getting 5 opinions
|
|
5
|
+
* and reading them all, you get one answer with a confidence score.
|
|
6
|
+
*
|
|
7
|
+
* Strategy options:
|
|
8
|
+
* - majority: >50% of models agree
|
|
9
|
+
* - supermajority: >=66% agree (for higher confidence)
|
|
10
|
+
* - unanimous: 100% agree (for critical decisions)
|
|
11
|
+
*
|
|
12
|
+
* How "agreement" works:
|
|
13
|
+
* We use a judge model to evaluate whether responses agree semantically.
|
|
14
|
+
* One of the available models gets picked as a judge (or the user can
|
|
15
|
+
* specify one). The judge reads all responses and groups them by
|
|
16
|
+
* agreement. This is way better than keyword matching because it
|
|
17
|
+
* understands that "start with a monolith" and "monolith, it's simpler"
|
|
18
|
+
* are the same answer.
|
|
19
|
+
*
|
|
20
|
+
* Falls back to naive keyword matching if the judge call fails.
|
|
21
|
+
*/
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { logger } from "../utils/logger.js";
|
|
24
|
+
export const consensusSchema = z.object({
|
|
25
|
+
models: z
|
|
26
|
+
.array(z.string())
|
|
27
|
+
.min(3)
|
|
28
|
+
.max(7)
|
|
29
|
+
.describe("List of model IDs to poll (3-7 models)"),
|
|
30
|
+
prompt: z.string().describe("The prompt to send to all models"),
|
|
31
|
+
strategy: z
|
|
32
|
+
.enum(["majority", "supermajority", "unanimous"])
|
|
33
|
+
.optional()
|
|
34
|
+
.default("majority")
|
|
35
|
+
.describe("Voting strategy — how many models must agree"),
|
|
36
|
+
judge_model: z.string().optional().describe("Optional model ID to use as judge. Auto-picks if not specified."),
|
|
37
|
+
system_prompt: z.string().optional(),
|
|
38
|
+
temperature: z.number().min(0).max(2).optional().default(0),
|
|
39
|
+
max_tokens: z.number().int().positive().optional().default(1024),
|
|
40
|
+
});
|
|
41
|
+
export async function consensus(provider, input) {
|
|
42
|
+
// Query all models in parallel
|
|
43
|
+
const results = await Promise.allSettled(input.models.map((model) => provider.query(model, input.prompt, {
|
|
44
|
+
system_prompt: input.system_prompt,
|
|
45
|
+
temperature: input.temperature,
|
|
46
|
+
max_tokens: input.max_tokens,
|
|
47
|
+
})));
|
|
48
|
+
const votes = results.map((result, i) => {
|
|
49
|
+
if (result.status === "fulfilled") {
|
|
50
|
+
return { model: input.models[i], content: result.value.content };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
model: input.models[i],
|
|
54
|
+
content: "",
|
|
55
|
+
error: result.reason instanceof Error
|
|
56
|
+
? result.reason.message
|
|
57
|
+
: String(result.reason),
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
const successful = votes.filter((v) => !v.error);
|
|
61
|
+
const failed = votes.filter((v) => v.error);
|
|
62
|
+
if (successful.length < 2) {
|
|
63
|
+
return `## Consensus Failed\n\nOnly ${successful.length} model(s) responded. Need at least 2 for consensus.\n\nErrors:\n${failed.map((f) => `- ${f.model}: ${f.error}`).join("\n")}`;
|
|
64
|
+
}
|
|
65
|
+
const threshold = getThreshold(input.strategy ?? "majority");
|
|
66
|
+
const requiredVotes = Math.ceil(successful.length * threshold);
|
|
67
|
+
// Use a judge model to determine agreement
|
|
68
|
+
const judgeModel = input.judge_model ?? await pickJudge(provider, input.models);
|
|
69
|
+
let agreeing;
|
|
70
|
+
let dissenting;
|
|
71
|
+
let judgeLatency;
|
|
72
|
+
if (judgeModel) {
|
|
73
|
+
logger.info(`consensus: using ${judgeModel} as judge`);
|
|
74
|
+
const judgeStart = Date.now();
|
|
75
|
+
const judgeResult = await judgeAgreement(provider, judgeModel, successful);
|
|
76
|
+
judgeLatency = Date.now() - judgeStart;
|
|
77
|
+
if (judgeResult) {
|
|
78
|
+
agreeing = judgeResult.agreeing;
|
|
79
|
+
dissenting = judgeResult.dissenting;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Judge failed, fall back to keyword matching
|
|
83
|
+
logger.warn("consensus: judge failed, falling back to keyword matching");
|
|
84
|
+
({ agreeing, dissenting } = keywordFallback(successful));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
logger.warn("consensus: no judge available, using keyword matching");
|
|
89
|
+
({ agreeing, dissenting } = keywordFallback(successful));
|
|
90
|
+
}
|
|
91
|
+
const reached = agreeing.length >= requiredVotes;
|
|
92
|
+
return formatConsensus({
|
|
93
|
+
reached,
|
|
94
|
+
strategy: input.strategy ?? "majority",
|
|
95
|
+
agreeing,
|
|
96
|
+
dissenting,
|
|
97
|
+
failed,
|
|
98
|
+
requiredVotes,
|
|
99
|
+
totalVoters: successful.length,
|
|
100
|
+
judgeModel,
|
|
101
|
+
judgeLatency,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function getThreshold(strategy) {
|
|
105
|
+
switch (strategy) {
|
|
106
|
+
case "majority":
|
|
107
|
+
return 0.5;
|
|
108
|
+
case "supermajority":
|
|
109
|
+
return 0.66;
|
|
110
|
+
case "unanimous":
|
|
111
|
+
return 1.0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Pick a judge model. Prefers a model not in the poll list so
|
|
116
|
+
* there's no conflict of interest. Falls back to first available
|
|
117
|
+
* if all models are in the poll.
|
|
118
|
+
*/
|
|
119
|
+
async function pickJudge(provider, polledModels) {
|
|
120
|
+
try {
|
|
121
|
+
const available = await provider.listModels();
|
|
122
|
+
if (available.length === 0)
|
|
123
|
+
return null;
|
|
124
|
+
// Prefer a model that's NOT being polled
|
|
125
|
+
const polledSet = new Set(polledModels.map((m) => m.toLowerCase()));
|
|
126
|
+
const outside = available.find((m) => !polledSet.has(m.id.toLowerCase()) && !polledSet.has(m.id.split("/").pop()?.toLowerCase() ?? ""));
|
|
127
|
+
if (outside)
|
|
128
|
+
return outside.id;
|
|
129
|
+
// Everyone's in the poll. Just use the first available model.
|
|
130
|
+
return available[0].id;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Ask a judge model to group responses by agreement.
|
|
138
|
+
* Returns the largest agreement group as "agreeing" and the rest as "dissenting".
|
|
139
|
+
*/
|
|
140
|
+
async function judgeAgreement(provider, judgeModel, votes) {
|
|
141
|
+
const responseSummary = votes
|
|
142
|
+
.map((v, i) => `Response ${i + 1} (${v.model}):\n${v.content}`)
|
|
143
|
+
.join("\n\n---\n\n");
|
|
144
|
+
const judgePrompt = `You are judging whether multiple AI model responses agree with each other.
|
|
145
|
+
|
|
146
|
+
Here are ${votes.length} responses to the same question:
|
|
147
|
+
|
|
148
|
+
${responseSummary}
|
|
149
|
+
|
|
150
|
+
Do these responses fundamentally agree on the same answer/position, even if they use different words or go into different levels of detail?
|
|
151
|
+
|
|
152
|
+
Reply with ONLY valid JSON in this exact format, no other text:
|
|
153
|
+
{"groups": [[0, 1, 2]], "reasoning": "all three say the same thing"}
|
|
154
|
+
|
|
155
|
+
Rules:
|
|
156
|
+
- Each group is an array of response numbers (0-indexed)
|
|
157
|
+
- Responses that agree go in the same group
|
|
158
|
+
- If all responses agree, put them all in one group like [[0, 1, 2]]
|
|
159
|
+
- If there are two camps, make two groups like [[0, 1], [2]]
|
|
160
|
+
- Focus on the substance of the answer, not the wording
|
|
161
|
+
- "reasoning" should be one short sentence`;
|
|
162
|
+
try {
|
|
163
|
+
const result = await provider.query(judgeModel, judgePrompt, {
|
|
164
|
+
temperature: 0,
|
|
165
|
+
max_tokens: 256,
|
|
166
|
+
});
|
|
167
|
+
// Parse the judge's JSON response
|
|
168
|
+
const jsonMatch = result.content.match(/\{[\s\S]*\}/);
|
|
169
|
+
if (!jsonMatch) {
|
|
170
|
+
logger.warn("consensus judge: no JSON found in response");
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
174
|
+
if (!parsed.groups || !Array.isArray(parsed.groups)) {
|
|
175
|
+
logger.warn("consensus judge: invalid groups format");
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
// Find the largest agreement group
|
|
179
|
+
const groups = parsed.groups;
|
|
180
|
+
const largest = groups.reduce((a, b) => (a.length >= b.length ? a : b), []);
|
|
181
|
+
const agreeing = largest.map((i) => votes[i]).filter(Boolean);
|
|
182
|
+
const agreeingSet = new Set(largest);
|
|
183
|
+
const dissenting = votes.filter((_, i) => !agreeingSet.has(i));
|
|
184
|
+
logger.info(`consensus judge: ${agreeing.length}/${votes.length} agree. ${parsed.reasoning ?? ""}`);
|
|
185
|
+
return { agreeing, dissenting };
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
logger.warn(`consensus judge failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Keyword-based fallback when no judge model is available.
|
|
194
|
+
* Naive but better than nothing.
|
|
195
|
+
*/
|
|
196
|
+
function keywordFallback(votes) {
|
|
197
|
+
const baseline = votes[0];
|
|
198
|
+
const agreeing = [baseline];
|
|
199
|
+
const dissenting = [];
|
|
200
|
+
for (let i = 1; i < votes.length; i++) {
|
|
201
|
+
if (responsesAgreeByKeywords(baseline.content, votes[i].content)) {
|
|
202
|
+
agreeing.push(votes[i]);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
dissenting.push(votes[i]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { agreeing, dissenting };
|
|
209
|
+
}
|
|
210
|
+
function responsesAgreeByKeywords(a, b) {
|
|
211
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter((w) => w.length > 4));
|
|
212
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter((w) => w.length > 4));
|
|
213
|
+
if (wordsA.size === 0 || wordsB.size === 0)
|
|
214
|
+
return false;
|
|
215
|
+
let overlap = 0;
|
|
216
|
+
for (const word of wordsA) {
|
|
217
|
+
if (wordsB.has(word))
|
|
218
|
+
overlap++;
|
|
219
|
+
}
|
|
220
|
+
const similarity = overlap / Math.max(wordsA.size, wordsB.size);
|
|
221
|
+
return similarity > 0.3;
|
|
222
|
+
}
|
|
223
|
+
function formatConsensus(result) {
|
|
224
|
+
const confidence = Math.round((result.agreeing.length / result.totalVoters) * 100);
|
|
225
|
+
const lines = [
|
|
226
|
+
`## Consensus: ${result.reached ? "REACHED" : "NOT REACHED"}`,
|
|
227
|
+
"",
|
|
228
|
+
`**Strategy:** ${result.strategy} (needed ${result.requiredVotes}/${result.totalVoters})`,
|
|
229
|
+
`**Agreement:** ${result.agreeing.length}/${result.totalVoters} models (${confidence}%)`,
|
|
230
|
+
result.judgeModel
|
|
231
|
+
? `**Judge:** ${result.judgeModel}${result.judgeLatency ? ` (${result.judgeLatency}ms)` : ""}`
|
|
232
|
+
: "",
|
|
233
|
+
"",
|
|
234
|
+
];
|
|
235
|
+
// Show the consensus answer (first agreeing model's response)
|
|
236
|
+
if (result.agreeing.length > 0) {
|
|
237
|
+
lines.push("### Consensus Response");
|
|
238
|
+
lines.push("");
|
|
239
|
+
lines.push(result.agreeing[0].content);
|
|
240
|
+
lines.push("");
|
|
241
|
+
lines.push(`*Agreed by: ${result.agreeing.map((v) => v.model).join(", ")}*`);
|
|
242
|
+
lines.push("");
|
|
243
|
+
}
|
|
244
|
+
// Show what each model actually said so the judge can be sanity-checked
|
|
245
|
+
const allVotes = [...result.agreeing, ...result.dissenting];
|
|
246
|
+
if (allVotes.length > 1) {
|
|
247
|
+
lines.push("### Individual Responses");
|
|
248
|
+
for (const v of allVotes) {
|
|
249
|
+
const summary = v.content.slice(0, 150).replace(/\n/g, " ");
|
|
250
|
+
lines.push(`- **${v.model}:** ${summary}${v.content.length > 150 ? "..." : ""}`);
|
|
251
|
+
}
|
|
252
|
+
lines.push("");
|
|
253
|
+
}
|
|
254
|
+
// Show dissent
|
|
255
|
+
if (result.dissenting.length > 0) {
|
|
256
|
+
lines.push("### Dissenting Views");
|
|
257
|
+
for (const d of result.dissenting) {
|
|
258
|
+
lines.push(`- **${d.model}:** ${d.content.slice(0, 200)}${d.content.length > 200 ? "..." : ""}`);
|
|
259
|
+
}
|
|
260
|
+
lines.push("");
|
|
261
|
+
}
|
|
262
|
+
// Show failures
|
|
263
|
+
if (result.failed.length > 0) {
|
|
264
|
+
lines.push(`*${result.failed.length} model(s) failed to respond*`);
|
|
265
|
+
}
|
|
266
|
+
return lines.join("\n");
|
|
267
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-recap — Two-pass session recap using a large-context model.
|
|
3
|
+
*
|
|
4
|
+
* Reads previous Claude Code sessions from disk (server-side), sends them
|
|
5
|
+
* to a large-context model like Gemini, and returns a smart-sized summary.
|
|
6
|
+
* Claude never sees the raw session data — only the distilled recap.
|
|
7
|
+
*
|
|
8
|
+
* How it works:
|
|
9
|
+
* 1. Read N recent session JSONL files from ~/.claude/projects/
|
|
10
|
+
* 2. Parse & filter (keep meaningful content, strip noise + secrets)
|
|
11
|
+
* 3. PASS 1: Send to model — "triage this, return event counts as JSON"
|
|
12
|
+
* 4. Calculate summary budget from triage results
|
|
13
|
+
* 5. PASS 2: Send to model — "write a recap in {budget} tokens"
|
|
14
|
+
* 6. Return only the recap to Claude
|
|
15
|
+
*/
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { Provider } from "../providers/provider.js";
|
|
18
|
+
export declare const sessionRecapSchema: z.ZodObject<{
|
|
19
|
+
sessions: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
20
|
+
project: z.ZodOptional<z.ZodString>;
|
|
21
|
+
focus: z.ZodOptional<z.ZodString>;
|
|
22
|
+
model: z.ZodOptional<z.ZodString>;
|
|
23
|
+
max_summary_tokens: z.ZodOptional<z.ZodNumber>;
|
|
24
|
+
}, "strip", z.ZodTypeAny, {
|
|
25
|
+
sessions: number;
|
|
26
|
+
model?: string | undefined;
|
|
27
|
+
project?: string | undefined;
|
|
28
|
+
focus?: string | undefined;
|
|
29
|
+
max_summary_tokens?: number | undefined;
|
|
30
|
+
}, {
|
|
31
|
+
model?: string | undefined;
|
|
32
|
+
sessions?: number | undefined;
|
|
33
|
+
project?: string | undefined;
|
|
34
|
+
focus?: string | undefined;
|
|
35
|
+
max_summary_tokens?: number | undefined;
|
|
36
|
+
}>;
|
|
37
|
+
export type SessionRecapInput = z.infer<typeof sessionRecapSchema>;
|
|
38
|
+
export declare function sessionRecap(provider: Provider, input: SessionRecapInput): Promise<string>;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-recap — Two-pass session recap using a large-context model.
|
|
3
|
+
*
|
|
4
|
+
* Reads previous Claude Code sessions from disk (server-side), sends them
|
|
5
|
+
* to a large-context model like Gemini, and returns a smart-sized summary.
|
|
6
|
+
* Claude never sees the raw session data — only the distilled recap.
|
|
7
|
+
*
|
|
8
|
+
* How it works:
|
|
9
|
+
* 1. Read N recent session JSONL files from ~/.claude/projects/
|
|
10
|
+
* 2. Parse & filter (keep meaningful content, strip noise + secrets)
|
|
11
|
+
* 3. PASS 1: Send to model — "triage this, return event counts as JSON"
|
|
12
|
+
* 4. Calculate summary budget from triage results
|
|
13
|
+
* 5. PASS 2: Send to model — "write a recap in {budget} tokens"
|
|
14
|
+
* 6. Return only the recap to Claude
|
|
15
|
+
*/
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { logger } from "../utils/logger.js";
|
|
18
|
+
import { readSessions, formatSessionsForPrompt, } from "../utils/session-reader.js";
|
|
19
|
+
import { pickLargeContextModel } from "../utils/model-selection.js";
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Schema
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export const sessionRecapSchema = z.object({
|
|
24
|
+
sessions: z
|
|
25
|
+
.number()
|
|
26
|
+
.int()
|
|
27
|
+
.min(1)
|
|
28
|
+
.max(10)
|
|
29
|
+
.optional()
|
|
30
|
+
.default(3)
|
|
31
|
+
.describe("Number of recent sessions to recap (default: 3)"),
|
|
32
|
+
project: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Project path to recap, e.g. 'C:\\\\Users\\\\Beast\\\\Documents\\\\GitHub\\\\MyProject'. Auto-detects most recent project if omitted."),
|
|
36
|
+
focus: z
|
|
37
|
+
.string()
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("Optional focus area to filter both triage and recap, e.g. 'auth implementation' or 'database migration'. When set, only events related to this topic are counted and summarized."),
|
|
40
|
+
model: z
|
|
41
|
+
.string()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Model to use for recap. Should be a large-context model like Gemini. Auto-picks if omitted."),
|
|
44
|
+
max_summary_tokens: z
|
|
45
|
+
.number()
|
|
46
|
+
.int()
|
|
47
|
+
.positive()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("Override the auto-calculated summary budget (in tokens). Auto-calculation ranges from 1K to 30K based on session density."),
|
|
50
|
+
});
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Pass 1: Triage
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
const TRIAGE_SYSTEM_PROMPT = `You are a precise code session analyzer. You read Claude Code conversation transcripts and extract structured metadata. Return ONLY valid JSON, no markdown, no explanation.`;
|
|
55
|
+
function buildTriagePrompt(sessionText, focus) {
|
|
56
|
+
const focusInstruction = focus
|
|
57
|
+
? `\n**FOCUS FILTER:** Only count events, files, decisions, and work related to: "${focus}". Ignore everything unrelated to this topic.\n`
|
|
58
|
+
: "";
|
|
59
|
+
return `Analyze these Claude Code session transcripts and return ONLY valid JSON with this exact structure:
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
"files_modified": ["src/auth.ts", "src/db.ts"],
|
|
63
|
+
"decisions_made": [{"summary": "Chose JWT over sessions for auth", "importance": "high"}],
|
|
64
|
+
"errors_resolved": [{"summary": "Fixed auth redirect loop in middleware", "importance": "medium"}],
|
|
65
|
+
"features_built": [{"summary": "User login flow with email verification", "status": "complete"}],
|
|
66
|
+
"unfinished_work": [{"summary": "Database migration script for v2 schema", "priority": "high"}],
|
|
67
|
+
"total_meaningful_events": 15
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
Rules:
|
|
71
|
+
- Count only substantive events. Greetings, small talk, and meta-questions are NOT events.
|
|
72
|
+
- A file being created/modified, a bug fixed, an architecture decision, a feature implemented — those ARE events.
|
|
73
|
+
- For files_modified, list unique file paths mentioned in tool calls or discussions.
|
|
74
|
+
- Importance/priority: "high", "medium", or "low".
|
|
75
|
+
- Status: "complete", "partial", or "planned".
|
|
76
|
+
${focusInstruction}
|
|
77
|
+
Session transcripts:
|
|
78
|
+
|
|
79
|
+
${sessionText}`;
|
|
80
|
+
}
|
|
81
|
+
function parseTriage(response) {
|
|
82
|
+
try {
|
|
83
|
+
// Extract JSON from response (handle any preamble/postamble)
|
|
84
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
85
|
+
if (!jsonMatch)
|
|
86
|
+
return null;
|
|
87
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
88
|
+
return {
|
|
89
|
+
files_modified: Array.isArray(parsed.files_modified)
|
|
90
|
+
? parsed.files_modified
|
|
91
|
+
: [],
|
|
92
|
+
decisions_made: Array.isArray(parsed.decisions_made)
|
|
93
|
+
? parsed.decisions_made
|
|
94
|
+
: [],
|
|
95
|
+
errors_resolved: Array.isArray(parsed.errors_resolved)
|
|
96
|
+
? parsed.errors_resolved
|
|
97
|
+
: [],
|
|
98
|
+
features_built: Array.isArray(parsed.features_built)
|
|
99
|
+
? parsed.features_built
|
|
100
|
+
: [],
|
|
101
|
+
unfinished_work: Array.isArray(parsed.unfinished_work)
|
|
102
|
+
? parsed.unfinished_work
|
|
103
|
+
: [],
|
|
104
|
+
total_meaningful_events: typeof parsed.total_meaningful_events === "number"
|
|
105
|
+
? parsed.total_meaningful_events
|
|
106
|
+
: 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Budget calculation
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
function calculateBudget(triage, totalInputChars, sessionCount) {
|
|
117
|
+
const inputTokens = Math.ceil(totalInputChars / 4);
|
|
118
|
+
const eventCount = triage.total_meaningful_events;
|
|
119
|
+
// Base: 4% of input tokens
|
|
120
|
+
const baseSummary = inputTokens * 0.04;
|
|
121
|
+
// Density factor: more events → bigger summary (0.5x to 2.0x)
|
|
122
|
+
const densityFactor = Math.max(0.5, Math.min(2.0, eventCount / 20));
|
|
123
|
+
let adjusted = baseSummary * densityFactor;
|
|
124
|
+
// Multi-session bonus: 30% more per additional session
|
|
125
|
+
adjusted *= 1 + (sessionCount - 1) * 0.3;
|
|
126
|
+
// Clamp to reasonable bounds
|
|
127
|
+
const MIN = 1000;
|
|
128
|
+
const MAX = 30000;
|
|
129
|
+
return Math.max(MIN, Math.min(MAX, Math.round(adjusted)));
|
|
130
|
+
}
|
|
131
|
+
function calculateWeights(triage) {
|
|
132
|
+
const counts = {
|
|
133
|
+
files: triage.files_modified.length,
|
|
134
|
+
decisions: triage.decisions_made.length,
|
|
135
|
+
features: triage.features_built.length,
|
|
136
|
+
errors: triage.errors_resolved.length,
|
|
137
|
+
unfinished: triage.unfinished_work.length,
|
|
138
|
+
};
|
|
139
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0) || 1;
|
|
140
|
+
// Each section gets proportional weight, minimum 10% each
|
|
141
|
+
const weights = {};
|
|
142
|
+
for (const [key, count] of Object.entries(counts)) {
|
|
143
|
+
weights[key] = Math.max(10, Math.round((count / total) * 100));
|
|
144
|
+
}
|
|
145
|
+
return weights;
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Pass 2: Recap
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
const RECAP_SYSTEM_PROMPT = `You are a developer context reconstructor. You read Claude Code session transcripts and produce structured, actionable recaps that let a developer resume work immediately. Be specific — include file paths, function names, error messages. No filler.`;
|
|
151
|
+
function buildRecapPrompt(sessionText, triage, budget, sessionCount, focus) {
|
|
152
|
+
const weights = calculateWeights(triage);
|
|
153
|
+
const focusInstruction = focus
|
|
154
|
+
? `\n**FOCUS AREA:** The developer specifically wants to know about: "${focus}". Prioritize information related to this topic.\n`
|
|
155
|
+
: "";
|
|
156
|
+
return `You are creating a session recap for a developer starting a new Claude Code session.
|
|
157
|
+
They need to understand what happened in their previous ${sessionCount} session(s) without reading the raw transcripts.
|
|
158
|
+
|
|
159
|
+
Your budget: approximately ${budget} tokens. Use it wisely — be dense, not verbose.
|
|
160
|
+
${focusInstruction}
|
|
161
|
+
Write a structured recap with these sections. Allocate space proportionally:
|
|
162
|
+
- ~${weights.files}% for **File Map** (${triage.files_modified.length} files detected)
|
|
163
|
+
- ~${weights.decisions}% for **Key Decisions** (${triage.decisions_made.length} decisions detected)
|
|
164
|
+
- ~${weights.features}% for **What Was Built** (${triage.features_built.length} features detected)
|
|
165
|
+
- ~${weights.errors}% for **Errors Resolved** (${triage.errors_resolved.length} errors detected)
|
|
166
|
+
- ~${weights.unfinished}% for **Unfinished / In Progress** (${triage.unfinished_work.length} items detected)
|
|
167
|
+
|
|
168
|
+
Required format:
|
|
169
|
+
|
|
170
|
+
## Project State
|
|
171
|
+
Current branch, key files, what's working. One paragraph max.
|
|
172
|
+
|
|
173
|
+
## What Was Built
|
|
174
|
+
- Feature: status, key files involved
|
|
175
|
+
|
|
176
|
+
## Key Decisions
|
|
177
|
+
- Decision: reasoning in one sentence
|
|
178
|
+
|
|
179
|
+
## Errors Resolved
|
|
180
|
+
- Error: how it was fixed, in which file
|
|
181
|
+
|
|
182
|
+
## Unfinished / In Progress
|
|
183
|
+
- Item: last known state, what's needed next
|
|
184
|
+
|
|
185
|
+
## File Map
|
|
186
|
+
- path — one-line description of what changed
|
|
187
|
+
|
|
188
|
+
Omit any section that has zero items. Be specific. Include file paths, function names, error messages. The developer needs actionable context, not vague summaries.
|
|
189
|
+
|
|
190
|
+
Session transcripts:
|
|
191
|
+
|
|
192
|
+
${sessionText}`;
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Main
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
export async function sessionRecap(provider, input) {
|
|
198
|
+
const startTime = Date.now();
|
|
199
|
+
// Step 1: Read sessions from disk
|
|
200
|
+
logger.info(`session_recap: reading ${input.sessions} sessions${input.project ? ` for ${input.project}` : " (auto-detect)"}`);
|
|
201
|
+
let bundle;
|
|
202
|
+
try {
|
|
203
|
+
bundle = readSessions(input.project, input.sessions);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
return `## Session Recap Failed\n\n${err instanceof Error ? err.message : String(err)}\n\n**Recovery:** If the project was not found, retry with an explicit project path. Run session_recap with the project parameter set to one of the available projects listed above.`;
|
|
207
|
+
}
|
|
208
|
+
if (bundle.sessions.length === 0) {
|
|
209
|
+
return "## Session Recap Failed\n\nNo sessions found to recap. The project directory exists but contains no .jsonl session files.\n\n**Recovery:** Try a different project path, or increase the sessions count. If the user recently started using Claude Code on this project, there may not be any history yet.";
|
|
210
|
+
}
|
|
211
|
+
const sessionText = formatSessionsForPrompt(bundle);
|
|
212
|
+
logger.info(`session_recap: ${bundle.sessionCount} sessions, ${bundle.totalChars} chars`);
|
|
213
|
+
// Step 2: Pick a model
|
|
214
|
+
const model = await pickLargeContextModel(provider, input.model);
|
|
215
|
+
if (!model) {
|
|
216
|
+
return "## Session Recap Failed\n\nNo models available for summarization.\n\n**Recovery:** The user needs to start a model provider. Tell them to start CLIProxyAPI or Ollama, then retry. You can also verify provider status by calling list_models first.";
|
|
217
|
+
}
|
|
218
|
+
logger.info(`session_recap: using model ${model}`);
|
|
219
|
+
// Step 3: Pass 1 — Triage
|
|
220
|
+
const triageStart = Date.now();
|
|
221
|
+
let triage = null;
|
|
222
|
+
try {
|
|
223
|
+
const triageResult = await provider.query(model, buildTriagePrompt(sessionText, input.focus), {
|
|
224
|
+
system_prompt: TRIAGE_SYSTEM_PROMPT,
|
|
225
|
+
temperature: 0,
|
|
226
|
+
max_tokens: 1024,
|
|
227
|
+
});
|
|
228
|
+
triage = parseTriage(triageResult.content);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
logger.warn(`session_recap: triage failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
232
|
+
}
|
|
233
|
+
const triageMs = Date.now() - triageStart;
|
|
234
|
+
// Step 4: Calculate budget
|
|
235
|
+
let budget;
|
|
236
|
+
if (input.max_summary_tokens) {
|
|
237
|
+
budget = input.max_summary_tokens;
|
|
238
|
+
}
|
|
239
|
+
else if (triage) {
|
|
240
|
+
budget = calculateBudget(triage, bundle.totalChars, bundle.sessionCount);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
// Fallback: fixed budget if triage failed
|
|
244
|
+
budget = 5000;
|
|
245
|
+
logger.warn("session_recap: using fallback budget of 5000 tokens");
|
|
246
|
+
}
|
|
247
|
+
// Use a default triage if pass 1 failed
|
|
248
|
+
const effectiveTriage = triage ?? {
|
|
249
|
+
files_modified: [],
|
|
250
|
+
decisions_made: [],
|
|
251
|
+
errors_resolved: [],
|
|
252
|
+
features_built: [],
|
|
253
|
+
unfinished_work: [],
|
|
254
|
+
total_meaningful_events: 0,
|
|
255
|
+
};
|
|
256
|
+
logger.info(`session_recap: budget=${budget} tokens, events=${effectiveTriage.total_meaningful_events}`);
|
|
257
|
+
// Step 5: Pass 2 — Full recap
|
|
258
|
+
const recapStart = Date.now();
|
|
259
|
+
try {
|
|
260
|
+
const recapResult = await provider.query(model, buildRecapPrompt(sessionText, effectiveTriage, budget, bundle.sessionCount, input.focus), {
|
|
261
|
+
system_prompt: RECAP_SYSTEM_PROMPT,
|
|
262
|
+
temperature: 0.2,
|
|
263
|
+
max_tokens: budget,
|
|
264
|
+
});
|
|
265
|
+
const recapMs = Date.now() - recapStart;
|
|
266
|
+
const totalMs = Date.now() - startTime;
|
|
267
|
+
// Format date range
|
|
268
|
+
const firstSession = bundle.sessions[bundle.sessions.length - 1];
|
|
269
|
+
const lastSession = bundle.sessions[0];
|
|
270
|
+
const dateRange = `${firstSession?.startTime?.slice(0, 10) ?? "?"} to ${lastSession?.endTime?.slice(0, 10) ?? "?"}`;
|
|
271
|
+
const lines = [
|
|
272
|
+
`## Session Recap (${bundle.sessionCount} session${bundle.sessionCount > 1 ? "s" : ""}, ${bundle.projectPath})`,
|
|
273
|
+
"",
|
|
274
|
+
`**Model:** ${model} | **Sessions:** ${dateRange} | **Budget:** ${budget} tokens`,
|
|
275
|
+
"",
|
|
276
|
+
recapResult.content,
|
|
277
|
+
"",
|
|
278
|
+
"---",
|
|
279
|
+
`*Recap generated by HydraMCP session_recap | Triage: ${triageMs}ms | Recap: ${recapMs}ms | Total: ${totalMs}ms*`,
|
|
280
|
+
];
|
|
281
|
+
return lines.join("\n");
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
// Graceful degradation: return triage results as basic summary
|
|
285
|
+
logger.error(`session_recap: recap pass failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
286
|
+
if (triage) {
|
|
287
|
+
return formatTriageFallback(triage, bundle, model);
|
|
288
|
+
}
|
|
289
|
+
return `## Session Recap Failed\n\nBoth triage and recap passes failed. Error: ${err instanceof Error ? err.message : String(err)}\n\n**Recovery:** Retry with fewer sessions (sessions=1) to reduce input size, or specify a different model. If the error mentions a timeout or rate limit, wait a moment and retry.`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Fallback formatter (when Pass 2 fails but triage succeeded)
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
function formatTriageFallback(triage, bundle, model) {
|
|
296
|
+
const lines = [
|
|
297
|
+
`## Session Recap — Triage Only (${bundle.sessionCount} sessions, ${bundle.projectPath})`,
|
|
298
|
+
"",
|
|
299
|
+
`*Full recap failed. Showing triage data from Pass 1.*`,
|
|
300
|
+
"",
|
|
301
|
+
`**Model:** ${model}`,
|
|
302
|
+
"",
|
|
303
|
+
];
|
|
304
|
+
if (triage.features_built.length > 0) {
|
|
305
|
+
lines.push("### What Was Built");
|
|
306
|
+
for (const f of triage.features_built) {
|
|
307
|
+
lines.push(`- ${f.summary} (${f.status ?? "unknown"})`);
|
|
308
|
+
}
|
|
309
|
+
lines.push("");
|
|
310
|
+
}
|
|
311
|
+
if (triage.decisions_made.length > 0) {
|
|
312
|
+
lines.push("### Key Decisions");
|
|
313
|
+
for (const d of triage.decisions_made) {
|
|
314
|
+
lines.push(`- ${d.summary} [${d.importance ?? "?"}]`);
|
|
315
|
+
}
|
|
316
|
+
lines.push("");
|
|
317
|
+
}
|
|
318
|
+
if (triage.errors_resolved.length > 0) {
|
|
319
|
+
lines.push("### Errors Resolved");
|
|
320
|
+
for (const e of triage.errors_resolved) {
|
|
321
|
+
lines.push(`- ${e.summary}`);
|
|
322
|
+
}
|
|
323
|
+
lines.push("");
|
|
324
|
+
}
|
|
325
|
+
if (triage.unfinished_work.length > 0) {
|
|
326
|
+
lines.push("### Unfinished Work");
|
|
327
|
+
for (const u of triage.unfinished_work) {
|
|
328
|
+
lines.push(`- ${u.summary} [${u.priority ?? "?"}]`);
|
|
329
|
+
}
|
|
330
|
+
lines.push("");
|
|
331
|
+
}
|
|
332
|
+
if (triage.files_modified.length > 0) {
|
|
333
|
+
lines.push("### Files Modified");
|
|
334
|
+
for (const f of triage.files_modified) {
|
|
335
|
+
lines.push(`- ${f}`);
|
|
336
|
+
}
|
|
337
|
+
lines.push("");
|
|
338
|
+
}
|
|
339
|
+
lines.push(`*Events detected: ${triage.total_meaningful_events}*`);
|
|
340
|
+
return lines.join("\n");
|
|
341
|
+
}
|