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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +192 -0
  3. package/dist/index.d.ts +31 -0
  4. package/dist/index.js +111 -0
  5. package/dist/orchestrator/cache.d.ts +44 -0
  6. package/dist/orchestrator/cache.js +80 -0
  7. package/dist/orchestrator/circuit-breaker.d.ts +34 -0
  8. package/dist/orchestrator/circuit-breaker.js +90 -0
  9. package/dist/orchestrator/config.d.ts +15 -0
  10. package/dist/orchestrator/config.js +50 -0
  11. package/dist/orchestrator/index.d.ts +37 -0
  12. package/dist/orchestrator/index.js +143 -0
  13. package/dist/orchestrator/metrics.d.ts +40 -0
  14. package/dist/orchestrator/metrics.js +77 -0
  15. package/dist/providers/anthropic.d.ts +20 -0
  16. package/dist/providers/anthropic.js +101 -0
  17. package/dist/providers/google.d.ts +18 -0
  18. package/dist/providers/google.js +123 -0
  19. package/dist/providers/multi-provider.d.ts +23 -0
  20. package/dist/providers/multi-provider.js +71 -0
  21. package/dist/providers/ollama.d.ts +26 -0
  22. package/dist/providers/ollama.js +87 -0
  23. package/dist/providers/openai.d.ts +17 -0
  24. package/dist/providers/openai.js +91 -0
  25. package/dist/providers/provider.d.ts +40 -0
  26. package/dist/providers/provider.js +9 -0
  27. package/dist/providers/subscription.d.ts +27 -0
  28. package/dist/providers/subscription.js +193 -0
  29. package/dist/server.d.ts +12 -0
  30. package/dist/server.js +238 -0
  31. package/dist/setup.d.ts +14 -0
  32. package/dist/setup.js +252 -0
  33. package/dist/tools/analyze-file.d.ts +40 -0
  34. package/dist/tools/analyze-file.js +227 -0
  35. package/dist/tools/ask-model.d.ts +49 -0
  36. package/dist/tools/ask-model.js +122 -0
  37. package/dist/tools/compare-models.d.ts +40 -0
  38. package/dist/tools/compare-models.js +104 -0
  39. package/dist/tools/consensus.d.ts +50 -0
  40. package/dist/tools/consensus.js +267 -0
  41. package/dist/tools/session-recap.d.ts +38 -0
  42. package/dist/tools/session-recap.js +341 -0
  43. package/dist/tools/smart-read.d.ts +45 -0
  44. package/dist/tools/smart-read.js +259 -0
  45. package/dist/tools/synthesize.d.ts +44 -0
  46. package/dist/tools/synthesize.js +182 -0
  47. package/dist/utils/compress.d.ts +27 -0
  48. package/dist/utils/compress.js +132 -0
  49. package/dist/utils/env.d.ts +11 -0
  50. package/dist/utils/env.js +44 -0
  51. package/dist/utils/logger.d.ts +14 -0
  52. package/dist/utils/logger.js +27 -0
  53. package/dist/utils/model-selection.d.ts +23 -0
  54. package/dist/utils/model-selection.js +54 -0
  55. package/dist/utils/session-reader.d.ts +67 -0
  56. package/dist/utils/session-reader.js +383 -0
  57. 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
+ }