smart-context-mcp 1.6.2 → 1.7.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/package.json +1 -1
- package/scripts/report-metrics.js +2 -2
- package/src/analytics/product-quality.js +176 -1
- package/src/hooks/claude-hooks.js +1 -423
- package/src/hooks/cursor-hooks.js +1 -0
- package/src/orchestration/adapters/claude-adapter.js +426 -0
- package/src/orchestration/adapters/cursor-adapter.js +429 -0
- package/src/orchestration/base-orchestrator.js +242 -0
- package/src/orchestration/headless-wrapper.js +33 -192
- package/src/orchestration/policy/event-policy.js +297 -0
- package/src/task-runner.js +24 -241
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smart-context-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "MCP server that reduces agent token usage by 90% with intelligent context compression, task checkpoint persistence, and workflow-aware agent guidance.",
|
|
5
5
|
"author": "Francisco Caballero Portero <fcp1978@hotmail.com>",
|
|
6
6
|
"type": "module",
|
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { smartMetrics } from '../src/tools/smart-metrics.js';
|
|
5
5
|
import { formatAdoptionReport } from '../src/analytics/adoption.js';
|
|
6
|
-
import { formatProductQualityReport } from '../src/analytics/product-quality.js';
|
|
6
|
+
import { formatProductQualityReport, hasProductQualitySignals } from '../src/analytics/product-quality.js';
|
|
7
7
|
|
|
8
8
|
const requireValue = (argv, index, flag) => {
|
|
9
9
|
const value = argv[index + 1];
|
|
@@ -101,7 +101,7 @@ const printHuman = (report) => {
|
|
|
101
101
|
console.log(formatAdoptionReport(report.adoption));
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
if (report.productQuality
|
|
104
|
+
if (hasProductQualitySignals(report.productQuality)) {
|
|
105
105
|
console.log(formatProductQualityReport(report.productQuality));
|
|
106
106
|
}
|
|
107
107
|
};
|
|
@@ -12,6 +12,139 @@ const isTaskRunnerQualityEntry = (entry) =>
|
|
|
12
12
|
entry?.tool === 'task_runner'
|
|
13
13
|
&& entry?.metadata?.analyticsKind === TASK_RUNNER_QUALITY_ANALYTICS_KIND;
|
|
14
14
|
|
|
15
|
+
const roundAverage = (total, count) =>
|
|
16
|
+
count > 0 ? Number((total / count).toFixed(1)) : 0;
|
|
17
|
+
|
|
18
|
+
const getMetricsClient = (entry) =>
|
|
19
|
+
entry?.metadata?.adapterClient
|
|
20
|
+
?? entry?.metadata?.client
|
|
21
|
+
?? null;
|
|
22
|
+
|
|
23
|
+
const hasMeasuredClientAdapters = (stats) =>
|
|
24
|
+
Number(stats?.clientAdapters?.clientsMeasured ?? 0) > 0;
|
|
25
|
+
|
|
26
|
+
const appendClientAdapterSignals = (lines, stats) => {
|
|
27
|
+
if (!hasMeasuredClientAdapters(stats)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const clients = stats.clientAdapters.byClient;
|
|
32
|
+
const lowestOverheadClient = clients.reduce((best, current) => {
|
|
33
|
+
if (!best) {
|
|
34
|
+
return current;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (current.averageContextOverheadTokens < best.averageContextOverheadTokens) {
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (current.averageContextOverheadTokens === best.averageContextOverheadTokens
|
|
42
|
+
&& current.client.localeCompare(best.client) < 0) {
|
|
43
|
+
return current;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return best;
|
|
47
|
+
}, null);
|
|
48
|
+
const highestAutoStartClient = clients.reduce((best, current) => {
|
|
49
|
+
if (!best) {
|
|
50
|
+
return current;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (current.autoStartCoveragePct > best.autoStartCoveragePct) {
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (current.autoStartCoveragePct === best.autoStartCoveragePct
|
|
58
|
+
&& current.client.localeCompare(best.client) < 0) {
|
|
59
|
+
return current;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return best;
|
|
63
|
+
}, null);
|
|
64
|
+
|
|
65
|
+
lines.push('Client Adapter Signals:');
|
|
66
|
+
lines.push(`Clients measured: ${stats.clientAdapters.clientsMeasured}`);
|
|
67
|
+
lines.push(`Adapter events: ${clients.reduce((total, client) => total + client.adapterEvents, 0)}`);
|
|
68
|
+
lines.push(`Overhead total: ${stats.clientAdapters.totalContextOverheadTokens} tokens`);
|
|
69
|
+
if (lowestOverheadClient) {
|
|
70
|
+
lines.push(`Lowest avg overhead: ${lowestOverheadClient.client} (${lowestOverheadClient.averageContextOverheadTokens} tokens)`);
|
|
71
|
+
}
|
|
72
|
+
if (highestAutoStartClient) {
|
|
73
|
+
lines.push(`Best auto-start rate: ${highestAutoStartClient.client} (${highestAutoStartClient.autoStartCoveragePct}%)`);
|
|
74
|
+
}
|
|
75
|
+
lines.push('');
|
|
76
|
+
|
|
77
|
+
for (const client of clients) {
|
|
78
|
+
lines.push(`${client.client}:`);
|
|
79
|
+
lines.push(` Entries measured: ${client.entriesMeasured}`);
|
|
80
|
+
lines.push(` Adapter coverage: ${client.adapterEvents}/${client.entriesMeasured} (${client.adapterCoveragePct}%)`);
|
|
81
|
+
lines.push(` Base orchestrated: ${client.baseOrchestratedEvents}/${client.entriesMeasured} (${client.baseOrchestratorCoveragePct}%)`);
|
|
82
|
+
lines.push(` Auto-started: ${client.autoStartedEvents}/${client.entriesMeasured} (${client.autoStartCoveragePct}%)`);
|
|
83
|
+
lines.push(` Auto-preflighted: ${client.autoPreflightedEvents}/${client.entriesMeasured} (${client.autoPreflightCoveragePct}%)`);
|
|
84
|
+
lines.push(` Auto-checkpointed: ${client.autoCheckpointedEvents}/${client.entriesMeasured} (${client.autoCheckpointCoveragePct}%)`);
|
|
85
|
+
lines.push(` Context overhead: ${client.contextOverheadTokens} tokens total (${client.averageContextOverheadTokens} avg)`);
|
|
86
|
+
if (client.blockedEvents > 0) {
|
|
87
|
+
lines.push(` Blocked events: ${client.blockedEvents}`);
|
|
88
|
+
}
|
|
89
|
+
lines.push('');
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const analyzeClientAdapterQuality = (entries = []) => {
|
|
94
|
+
const clientEntries = entries.filter((entry) => Boolean(getMetricsClient(entry)));
|
|
95
|
+
|
|
96
|
+
const byClient = [...clientEntries.reduce((acc, entry) => {
|
|
97
|
+
const client = getMetricsClient(entry);
|
|
98
|
+
const current = acc.get(client) ?? {
|
|
99
|
+
client,
|
|
100
|
+
entriesMeasured: 0,
|
|
101
|
+
adapterEvents: 0,
|
|
102
|
+
wrapperEvents: 0,
|
|
103
|
+
taskRunnerEvents: 0,
|
|
104
|
+
baseOrchestratedEvents: 0,
|
|
105
|
+
autoStartedEvents: 0,
|
|
106
|
+
autoPreflightedEvents: 0,
|
|
107
|
+
autoCheckpointedEvents: 0,
|
|
108
|
+
blockedEvents: 0,
|
|
109
|
+
contextOverheadEntries: 0,
|
|
110
|
+
contextOverheadTokens: 0,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const overheadTokens = Math.max(0, Number(entry.metadata?.overheadTokens ?? 0));
|
|
114
|
+
current.entriesMeasured += 1;
|
|
115
|
+
current.adapterEvents += entry.metadata?.managedByClientAdapter ? 1 : 0;
|
|
116
|
+
current.wrapperEvents += entry.tool === 'agent_wrapper' ? 1 : 0;
|
|
117
|
+
current.taskRunnerEvents += entry.tool === 'task_runner' ? 1 : 0;
|
|
118
|
+
current.baseOrchestratedEvents += entry.metadata?.managedByBaseOrchestrator ? 1 : 0;
|
|
119
|
+
current.autoStartedEvents += (entry.metadata?.autoStartTriggered || entry.metadata?.autoStarted) ? 1 : 0;
|
|
120
|
+
current.autoPreflightedEvents += entry.metadata?.autoPreflightTriggered ? 1 : 0;
|
|
121
|
+
current.autoCheckpointedEvents += (entry.metadata?.autoCheckpointTriggered || entry.metadata?.autoAppended) ? 1 : 0;
|
|
122
|
+
current.blockedEvents += entry.metadata?.blocked ? 1 : 0;
|
|
123
|
+
current.contextOverheadEntries += overheadTokens > 0 ? 1 : 0;
|
|
124
|
+
current.contextOverheadTokens += overheadTokens;
|
|
125
|
+
|
|
126
|
+
acc.set(client, current);
|
|
127
|
+
return acc;
|
|
128
|
+
}, new Map()).values()]
|
|
129
|
+
.map((entry) => ({
|
|
130
|
+
...entry,
|
|
131
|
+
averageContextOverheadTokens: roundAverage(entry.contextOverheadTokens, entry.contextOverheadEntries),
|
|
132
|
+
adapterCoveragePct: roundPct(entry.adapterEvents, entry.entriesMeasured),
|
|
133
|
+
baseOrchestratorCoveragePct: roundPct(entry.baseOrchestratedEvents, entry.entriesMeasured),
|
|
134
|
+
autoStartCoveragePct: roundPct(entry.autoStartedEvents, entry.entriesMeasured),
|
|
135
|
+
autoPreflightCoveragePct: roundPct(entry.autoPreflightedEvents, entry.entriesMeasured),
|
|
136
|
+
autoCheckpointCoveragePct: roundPct(entry.autoCheckpointedEvents, entry.entriesMeasured),
|
|
137
|
+
}))
|
|
138
|
+
.sort((a, b) => a.client.localeCompare(b.client));
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
clientsMeasured: byClient.length,
|
|
142
|
+
entriesMeasured: clientEntries.length,
|
|
143
|
+
totalContextOverheadTokens: byClient.reduce((total, entry) => total + entry.contextOverheadTokens, 0),
|
|
144
|
+
byClient,
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
15
148
|
const analyzeTaskRunnerQuality = (entries = []) => {
|
|
16
149
|
const runnerEntries = entries.filter(isTaskRunnerQualityEntry);
|
|
17
150
|
const workflowEntries = runnerEntries.filter((entry) => entry.metadata?.isWorkflowCommand);
|
|
@@ -25,6 +158,16 @@ const analyzeTaskRunnerQuality = (entries = []) => {
|
|
|
25
158
|
const blockedWithDoctor = blockedEntries.filter((entry) => entry.metadata?.doctorIssued);
|
|
26
159
|
const checkpointEntries = runnerEntries.filter((entry) => entry.action === 'checkpoint');
|
|
27
160
|
const persistedCheckpointEntries = checkpointEntries.filter((entry) => entry.metadata?.checkpointPersisted);
|
|
161
|
+
const baseOrchestratedEntries = workflowEntries.filter((entry) => entry.metadata?.managedByBaseOrchestrator);
|
|
162
|
+
const autoStartedEntries = workflowEntries.filter((entry) => entry.metadata?.autoStartTriggered);
|
|
163
|
+
const autoPreflightEntries = workflowEntries.filter((entry) => entry.metadata?.autoPreflightTriggered);
|
|
164
|
+
const autoCheckpointEntries = runnerEntries.filter((entry) => entry.metadata?.autoCheckpointTriggered);
|
|
165
|
+
const isolatedWorkflowEntries = workflowEntries.filter((entry) => entry.metadata?.isolatedSession);
|
|
166
|
+
const contextOverheadEntries = runnerEntries.filter((entry) => Number(entry.metadata?.contextOverheadTokens ?? 0) > 0);
|
|
167
|
+
const contextOverheadTokens = runnerEntries.reduce(
|
|
168
|
+
(total, entry) => total + Number(entry.metadata?.contextOverheadTokens ?? 0),
|
|
169
|
+
0,
|
|
170
|
+
);
|
|
28
171
|
|
|
29
172
|
const commandBreakdown = [...runnerEntries.reduce((acc, entry) => {
|
|
30
173
|
const key = entry.action ?? 'unknown';
|
|
@@ -71,6 +214,20 @@ const analyzeTaskRunnerQuality = (entries = []) => {
|
|
|
71
214
|
persistedCommands: persistedCheckpointEntries.length,
|
|
72
215
|
persistenceRatePct: roundPct(persistedCheckpointEntries.length, checkpointEntries.length),
|
|
73
216
|
},
|
|
217
|
+
automaticity: {
|
|
218
|
+
baseOrchestratedCommands: baseOrchestratedEntries.length,
|
|
219
|
+
autoStartedCommands: autoStartedEntries.length,
|
|
220
|
+
autoPreflightedCommands: autoPreflightEntries.length,
|
|
221
|
+
autoCheckpointedCommands: autoCheckpointEntries.length,
|
|
222
|
+
isolatedWorkflowCommands: isolatedWorkflowEntries.length,
|
|
223
|
+
contextOverheadTokens,
|
|
224
|
+
averageContextOverheadTokens: contextOverheadEntries.length > 0
|
|
225
|
+
? Number((contextOverheadTokens / contextOverheadEntries.length).toFixed(1))
|
|
226
|
+
: 0,
|
|
227
|
+
baseOrchestratorCoveragePct: roundPct(baseOrchestratedEntries.length, workflowEntries.length),
|
|
228
|
+
autoStartCoveragePct: roundPct(autoStartedEntries.length, workflowEntries.length),
|
|
229
|
+
autoPreflightCoveragePct: roundPct(autoPreflightEntries.length, workflowEntries.length),
|
|
230
|
+
},
|
|
74
231
|
};
|
|
75
232
|
};
|
|
76
233
|
|
|
@@ -132,15 +289,24 @@ export const analyzeProductQuality = (entries = []) => {
|
|
|
132
289
|
blockedEnds,
|
|
133
290
|
persistenceRatePct: roundPct(persistedEnds, endEntries.length),
|
|
134
291
|
},
|
|
292
|
+
clientAdapters: analyzeClientAdapterQuality(entries),
|
|
135
293
|
taskRunner: analyzeTaskRunnerQuality(entries),
|
|
136
294
|
};
|
|
137
295
|
};
|
|
138
296
|
|
|
297
|
+
export const hasProductQualitySignals = (stats) =>
|
|
298
|
+
Boolean(stats)
|
|
299
|
+
&& (
|
|
300
|
+
Number(stats.turnsMeasured ?? 0) > 0
|
|
301
|
+
|| Number(stats?.taskRunner?.commandsMeasured ?? 0) > 0
|
|
302
|
+
|| hasMeasuredClientAdapters(stats)
|
|
303
|
+
);
|
|
304
|
+
|
|
139
305
|
export const formatProductQualityReport = (stats) => {
|
|
140
306
|
const hasSmartTurn = Number(stats?.turnsMeasured ?? 0) > 0;
|
|
141
307
|
const hasTaskRunner = Number(stats?.taskRunner?.commandsMeasured ?? 0) > 0;
|
|
142
308
|
|
|
143
|
-
if (!stats
|
|
309
|
+
if (!hasProductQualitySignals(stats)) {
|
|
144
310
|
return '';
|
|
145
311
|
}
|
|
146
312
|
|
|
@@ -193,11 +359,20 @@ export const formatProductQualityReport = (stats) => {
|
|
|
193
359
|
lines.push('Blocked-State Routing:');
|
|
194
360
|
lines.push(`Blocked with doctor: ${stats.taskRunner.blockedState.blockedWithDoctor}/${stats.taskRunner.blockedState.blockedCommands} (${stats.taskRunner.blockedState.doctorCoveragePct}%)`);
|
|
195
361
|
lines.push('');
|
|
362
|
+
lines.push('Automaticity:');
|
|
363
|
+
lines.push(`Base orchestrated: ${stats.taskRunner.automaticity.baseOrchestratedCommands}/${stats.taskRunner.workflowCommands} (${stats.taskRunner.automaticity.baseOrchestratorCoveragePct}%)`);
|
|
364
|
+
lines.push(`Auto-started: ${stats.taskRunner.automaticity.autoStartedCommands}/${stats.taskRunner.workflowCommands} (${stats.taskRunner.automaticity.autoStartCoveragePct}%)`);
|
|
365
|
+
lines.push(`Auto-preflighted: ${stats.taskRunner.automaticity.autoPreflightedCommands}/${stats.taskRunner.workflowCommands} (${stats.taskRunner.automaticity.autoPreflightCoveragePct}%)`);
|
|
366
|
+
lines.push(`Auto-checkpointed: ${stats.taskRunner.automaticity.autoCheckpointedCommands}`);
|
|
367
|
+
lines.push(`Context overhead: ${stats.taskRunner.automaticity.contextOverheadTokens} tokens total (${stats.taskRunner.automaticity.averageContextOverheadTokens} avg)`);
|
|
368
|
+
lines.push('');
|
|
196
369
|
lines.push('Checkpoint Commands:');
|
|
197
370
|
lines.push(`Persisted checkpoints: ${stats.taskRunner.checkpointing.persistedCommands}/${stats.taskRunner.checkpointing.commandsMeasured} (${stats.taskRunner.checkpointing.persistenceRatePct}%)`);
|
|
198
371
|
lines.push('');
|
|
199
372
|
}
|
|
200
373
|
|
|
374
|
+
appendClientAdapterSignals(lines, stats);
|
|
375
|
+
|
|
201
376
|
lines.push('Notes:');
|
|
202
377
|
lines.push('- These are measured orchestration signals, not direct answer-quality scores.');
|
|
203
378
|
lines.push('- Context refresh usefulness is proxied by whether refreshed turns surfaced top-file signals.');
|
|
@@ -1,423 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { buildOperationalContextLines } from '../client-contract.js';
|
|
3
|
-
import { getRepoMutationSafety } from '../repo-safety.js';
|
|
4
|
-
import { countTokens } from '../tokenCounter.js';
|
|
5
|
-
import { smartSummary } from '../tools/smart-summary.js';
|
|
6
|
-
import { smartTurn } from '../tools/smart-turn.js';
|
|
7
|
-
import {
|
|
8
|
-
deleteHookTurnState,
|
|
9
|
-
getHookTurnState,
|
|
10
|
-
setHookTurnState,
|
|
11
|
-
} from '../storage/sqlite.js';
|
|
12
|
-
|
|
13
|
-
const HOOK_CLIENT = 'claude';
|
|
14
|
-
const START_MAX_TOKENS = 350;
|
|
15
|
-
const STOP_MAX_TOKENS = 300;
|
|
16
|
-
const MAX_CONTEXT_LINES = 7;
|
|
17
|
-
const MAX_CONTEXT_CHARS = 420;
|
|
18
|
-
const MAX_PROMPT_PREVIEW = 160;
|
|
19
|
-
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
|
|
20
|
-
const SIGNIFICANT_RESPONSE_LENGTH = 140;
|
|
21
|
-
|
|
22
|
-
const normalizeWhitespace = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
23
|
-
|
|
24
|
-
const truncate = (value, maxLength = MAX_PROMPT_PREVIEW) => {
|
|
25
|
-
const normalized = normalizeWhitespace(value);
|
|
26
|
-
if (normalized.length <= maxLength) {
|
|
27
|
-
return normalized;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (maxLength <= 3) {
|
|
31
|
-
return '';
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return `${normalized.slice(0, maxLength - 3)}...`;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const countPromptTerms = (value) =>
|
|
38
|
-
normalizeWhitespace(value)
|
|
39
|
-
.split(/[^a-z0-9_.-]+/i)
|
|
40
|
-
.map((term) => term.trim())
|
|
41
|
-
.filter((term) => term.length >= 3)
|
|
42
|
-
.length;
|
|
43
|
-
|
|
44
|
-
const isMeaningfulPrompt = (value) => {
|
|
45
|
-
const normalized = normalizeWhitespace(value);
|
|
46
|
-
return normalized.length >= 20 && countPromptTerms(normalized) >= 4;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const uniq = (values) => [...new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0))];
|
|
50
|
-
|
|
51
|
-
const buildHookKey = ({ sessionId, agentId = null }) =>
|
|
52
|
-
agentId ? `${HOOK_CLIENT}:subagent:${sessionId}:${agentId}` : `${HOOK_CLIENT}:main:${sessionId}`;
|
|
53
|
-
|
|
54
|
-
const buildAdditionalContext = ({ result, sessionStart = false }) => {
|
|
55
|
-
return buildOperationalContextLines(result, {
|
|
56
|
-
sessionStart,
|
|
57
|
-
maxLineLength: 110,
|
|
58
|
-
maxLines: MAX_CONTEXT_LINES,
|
|
59
|
-
maxChars: MAX_CONTEXT_CHARS,
|
|
60
|
-
});
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const buildHookContextResponse = (hookEventName, additionalContext) => {
|
|
64
|
-
if (!additionalContext) {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return {
|
|
69
|
-
hookSpecificOutput: {
|
|
70
|
-
hookEventName,
|
|
71
|
-
additionalContext,
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const readHookTurnState = (hookKey) =>
|
|
77
|
-
getHookTurnState({
|
|
78
|
-
hookKey,
|
|
79
|
-
readOnly: getRepoMutationSafety().shouldBlock,
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const maybeSetTrackedTurnState = async ({ hookKey, state }) => {
|
|
83
|
-
if (getRepoMutationSafety().shouldBlock) {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return setHookTurnState({ hookKey, state });
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const maybeDeleteTrackedTurnState = async ({ hookKey }) => {
|
|
91
|
-
if (getRepoMutationSafety().shouldBlock) {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return deleteHookTurnState({ hookKey });
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const recordHookMetrics = async ({
|
|
99
|
-
action,
|
|
100
|
-
sessionId,
|
|
101
|
-
additionalContext = '',
|
|
102
|
-
blocked = false,
|
|
103
|
-
autoAppended = false,
|
|
104
|
-
continuityState = null,
|
|
105
|
-
} = {}) => {
|
|
106
|
-
const overheadTokens = additionalContext ? countTokens(additionalContext) : 0;
|
|
107
|
-
|
|
108
|
-
await persistMetrics({
|
|
109
|
-
tool: 'claude_hook',
|
|
110
|
-
action,
|
|
111
|
-
sessionId,
|
|
112
|
-
rawTokens: 0,
|
|
113
|
-
compressedTokens: 0,
|
|
114
|
-
savedTokens: 0,
|
|
115
|
-
savingsPct: 0,
|
|
116
|
-
metadata: {
|
|
117
|
-
isContextOverhead: overheadTokens > 0,
|
|
118
|
-
overheadTokens,
|
|
119
|
-
blocked,
|
|
120
|
-
autoAppended,
|
|
121
|
-
continuityState,
|
|
122
|
-
},
|
|
123
|
-
timestamp: new Date().toISOString(),
|
|
124
|
-
});
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
const isSmartTurnTool = (toolName) => /^mcp__.+__smart_turn$/i.test(toolName ?? '');
|
|
128
|
-
const isSmartSummaryTool = (toolName) => /^mcp__.+__smart_summary$/i.test(toolName ?? '');
|
|
129
|
-
|
|
130
|
-
const isCheckpointToolUse = ({ toolName, toolInput }) => {
|
|
131
|
-
if (isSmartTurnTool(toolName)) {
|
|
132
|
-
return toolInput?.phase === 'end'
|
|
133
|
-
? { matched: true, event: toolInput?.event ?? 'manual' }
|
|
134
|
-
: { matched: false, event: null };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (isSmartSummaryTool(toolName)) {
|
|
138
|
-
const action = toolInput?.action;
|
|
139
|
-
if (action === 'checkpoint') {
|
|
140
|
-
return { matched: true, event: toolInput?.event ?? 'manual' };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (action === 'append' || action === 'auto_append' || action === 'update') {
|
|
144
|
-
return { matched: true, event: action };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return { matched: false, event: null };
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const extractTouchedFiles = ({ toolName, toolInput, toolResponse }) => {
|
|
152
|
-
if (!WRITE_TOOLS.has(toolName)) {
|
|
153
|
-
return [];
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return uniq([
|
|
157
|
-
toolInput?.file_path,
|
|
158
|
-
toolInput?.filePath,
|
|
159
|
-
toolResponse?.file_path,
|
|
160
|
-
toolResponse?.filePath,
|
|
161
|
-
]);
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const extractNextStep = (message) => {
|
|
165
|
-
const normalized = normalizeWhitespace(message);
|
|
166
|
-
if (!normalized) {
|
|
167
|
-
return '';
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const explicitMatch = normalized.match(/(?:next step|siguiente paso)\s*[:\-]\s*([^.;\n]{12,180})/i);
|
|
171
|
-
if (explicitMatch?.[1]) {
|
|
172
|
-
return truncate(explicitMatch[1], 150);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return '';
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
const buildCarryoverUpdate = (state, lastAssistantMessage) => {
|
|
179
|
-
const promptPreview = truncate(state.promptPreview, 140);
|
|
180
|
-
const nextStep = extractNextStep(lastAssistantMessage);
|
|
181
|
-
const pinnedContext = promptPreview ? [`Uncheckpointed turn: ${promptPreview}`] : [];
|
|
182
|
-
|
|
183
|
-
return {
|
|
184
|
-
...(promptPreview ? { currentFocus: promptPreview } : {}),
|
|
185
|
-
...(pinnedContext.length > 0 ? { pinnedContext } : {}),
|
|
186
|
-
...(state.touchedFiles.length > 0 ? { touchedFiles: state.touchedFiles } : {}),
|
|
187
|
-
...(nextStep ? { nextStep } : {}),
|
|
188
|
-
};
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
const computeStopEnforcement = (state, lastAssistantMessage) => {
|
|
192
|
-
const nextStep = extractNextStep(lastAssistantMessage);
|
|
193
|
-
const responseLength = normalizeWhitespace(lastAssistantMessage).length;
|
|
194
|
-
let score = 0;
|
|
195
|
-
|
|
196
|
-
if (state.meaningfulWriteCount > 0) {
|
|
197
|
-
score += 3;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (state.touchedFiles.length > 0) {
|
|
201
|
-
score += 1;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (nextStep) {
|
|
205
|
-
score += 2;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (responseLength >= SIGNIFICANT_RESPONSE_LENGTH) {
|
|
209
|
-
score += 1;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (state.continuityState === 'task_switch' || state.continuityState === 'possible_shift') {
|
|
213
|
-
score += 1;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
shouldBlock: score >= 3,
|
|
218
|
-
score,
|
|
219
|
-
nextStep,
|
|
220
|
-
};
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const maybeTrackTurn = async ({
|
|
224
|
-
hookKey,
|
|
225
|
-
claudeSessionId,
|
|
226
|
-
projectSessionId,
|
|
227
|
-
prompt,
|
|
228
|
-
continuityState,
|
|
229
|
-
}) => {
|
|
230
|
-
const promptMeaningful = isMeaningfulPrompt(prompt);
|
|
231
|
-
const shouldTrack = Boolean(projectSessionId) && promptMeaningful;
|
|
232
|
-
|
|
233
|
-
if (!shouldTrack) {
|
|
234
|
-
await deleteHookTurnState({ hookKey });
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return maybeSetTrackedTurnState({
|
|
239
|
-
hookKey,
|
|
240
|
-
state: {
|
|
241
|
-
client: HOOK_CLIENT,
|
|
242
|
-
claudeSessionId,
|
|
243
|
-
projectSessionId,
|
|
244
|
-
turnId: `${claudeSessionId}:${Date.now()}`,
|
|
245
|
-
promptPreview: truncate(prompt),
|
|
246
|
-
continuityState,
|
|
247
|
-
requireCheckpoint: true,
|
|
248
|
-
promptMeaningful,
|
|
249
|
-
checkpointed: false,
|
|
250
|
-
checkpointEvent: null,
|
|
251
|
-
touchedFiles: [],
|
|
252
|
-
meaningfulWriteCount: 0,
|
|
253
|
-
},
|
|
254
|
-
});
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
const handleSessionStart = async (input) => {
|
|
258
|
-
const result = await smartTurn({
|
|
259
|
-
phase: 'start',
|
|
260
|
-
maxTokens: START_MAX_TOKENS,
|
|
261
|
-
});
|
|
262
|
-
const additionalContext = buildAdditionalContext({ result, sessionStart: true });
|
|
263
|
-
await recordHookMetrics({
|
|
264
|
-
action: 'SessionStart',
|
|
265
|
-
sessionId: result.sessionId ?? null,
|
|
266
|
-
additionalContext,
|
|
267
|
-
continuityState: result.continuity?.state ?? null,
|
|
268
|
-
});
|
|
269
|
-
return buildHookContextResponse('SessionStart', additionalContext);
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
const handleUserPromptSubmit = async (input) => {
|
|
273
|
-
const result = await smartTurn({
|
|
274
|
-
phase: 'start',
|
|
275
|
-
prompt: input.prompt,
|
|
276
|
-
ensureSession: true,
|
|
277
|
-
maxTokens: START_MAX_TOKENS,
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
const trackedState = await maybeTrackTurn({
|
|
281
|
-
hookKey: buildHookKey({ sessionId: input.session_id }),
|
|
282
|
-
claudeSessionId: input.session_id,
|
|
283
|
-
projectSessionId: result.sessionId ?? null,
|
|
284
|
-
prompt: input.prompt,
|
|
285
|
-
continuityState: result.continuity?.state ?? '',
|
|
286
|
-
});
|
|
287
|
-
const additionalContext = buildAdditionalContext({ result });
|
|
288
|
-
await recordHookMetrics({
|
|
289
|
-
action: 'UserPromptSubmit',
|
|
290
|
-
sessionId: trackedState?.projectSessionId ?? result.sessionId ?? null,
|
|
291
|
-
additionalContext,
|
|
292
|
-
continuityState: result.continuity?.state ?? null,
|
|
293
|
-
});
|
|
294
|
-
return buildHookContextResponse('UserPromptSubmit', additionalContext);
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
const handlePostToolUse = async (input) => {
|
|
298
|
-
const hookKey = buildHookKey({ sessionId: input.session_id });
|
|
299
|
-
const existing = await readHookTurnState(hookKey);
|
|
300
|
-
if (!existing) {
|
|
301
|
-
return null;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const checkpoint = isCheckpointToolUse({
|
|
305
|
-
toolName: input.tool_name,
|
|
306
|
-
toolInput: input.tool_input,
|
|
307
|
-
});
|
|
308
|
-
const touchedFiles = extractTouchedFiles({
|
|
309
|
-
toolName: input.tool_name,
|
|
310
|
-
toolInput: input.tool_input,
|
|
311
|
-
toolResponse: input.tool_response,
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
const nextState = {
|
|
315
|
-
...existing,
|
|
316
|
-
checkpointed: checkpoint.matched ? true : existing.checkpointed,
|
|
317
|
-
checkpointEvent: checkpoint.matched ? checkpoint.event : existing.checkpointEvent,
|
|
318
|
-
touchedFiles: uniq([...existing.touchedFiles, ...touchedFiles]),
|
|
319
|
-
meaningfulWriteCount: existing.meaningfulWriteCount + touchedFiles.length,
|
|
320
|
-
updatedAt: new Date().toISOString(),
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
await maybeSetTrackedTurnState({ hookKey, state: nextState });
|
|
324
|
-
if (checkpoint.matched || touchedFiles.length > 0) {
|
|
325
|
-
await recordHookMetrics({
|
|
326
|
-
action: 'PostToolUse',
|
|
327
|
-
sessionId: existing.projectSessionId,
|
|
328
|
-
additionalContext: '',
|
|
329
|
-
continuityState: existing.continuityState,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
return null;
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
const handleStop = async (input) => {
|
|
336
|
-
const hookKey = buildHookKey({ sessionId: input.session_id });
|
|
337
|
-
const state = await readHookTurnState(hookKey);
|
|
338
|
-
if (!state) {
|
|
339
|
-
return null;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (getRepoMutationSafety().shouldBlock) {
|
|
343
|
-
await recordHookMetrics({
|
|
344
|
-
action: 'Stop',
|
|
345
|
-
sessionId: state.projectSessionId,
|
|
346
|
-
additionalContext: '',
|
|
347
|
-
blocked: false,
|
|
348
|
-
continuityState: state.continuityState,
|
|
349
|
-
});
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const enforcement = computeStopEnforcement(state, input.last_assistant_message);
|
|
354
|
-
const shouldEnforce = (state.requireCheckpoint || state.meaningfulWriteCount > 0) && enforcement.shouldBlock;
|
|
355
|
-
if (!shouldEnforce || state.checkpointed) {
|
|
356
|
-
await recordHookMetrics({
|
|
357
|
-
action: 'Stop',
|
|
358
|
-
sessionId: state.projectSessionId,
|
|
359
|
-
additionalContext: '',
|
|
360
|
-
blocked: false,
|
|
361
|
-
continuityState: state.continuityState,
|
|
362
|
-
});
|
|
363
|
-
await maybeDeleteTrackedTurnState({ hookKey });
|
|
364
|
-
return null;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
if (input.stop_hook_active) {
|
|
368
|
-
const update = buildCarryoverUpdate(state, input.last_assistant_message);
|
|
369
|
-
if (state.projectSessionId && Object.keys(update).length > 0) {
|
|
370
|
-
await smartSummary({
|
|
371
|
-
action: 'auto_append',
|
|
372
|
-
sessionId: state.projectSessionId,
|
|
373
|
-
update,
|
|
374
|
-
maxTokens: STOP_MAX_TOKENS,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
await recordHookMetrics({
|
|
379
|
-
action: 'Stop',
|
|
380
|
-
sessionId: state.projectSessionId,
|
|
381
|
-
additionalContext: '',
|
|
382
|
-
blocked: false,
|
|
383
|
-
autoAppended: true,
|
|
384
|
-
continuityState: state.continuityState,
|
|
385
|
-
});
|
|
386
|
-
await maybeDeleteTrackedTurnState({ hookKey });
|
|
387
|
-
return null;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
await recordHookMetrics({
|
|
391
|
-
action: 'Stop',
|
|
392
|
-
sessionId: state.projectSessionId,
|
|
393
|
-
additionalContext: '',
|
|
394
|
-
blocked: true,
|
|
395
|
-
continuityState: state.continuityState,
|
|
396
|
-
});
|
|
397
|
-
return {
|
|
398
|
-
decision: 'block',
|
|
399
|
-
reason: `Persist this turn with mcp__devctx__smart_turn phase=end before stopping.${state.touchedFiles.length > 0 ? ' Include touchedFiles and the nextStep.' : ' Include the nextStep.'}`,
|
|
400
|
-
};
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
export const handleClaudeHookEvent = async (input = {}) => {
|
|
404
|
-
const eventName = input.hook_event_name;
|
|
405
|
-
|
|
406
|
-
if (eventName === 'SessionStart') {
|
|
407
|
-
return handleSessionStart(input);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
if (eventName === 'UserPromptSubmit') {
|
|
411
|
-
return handleUserPromptSubmit(input);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (eventName === 'PostToolUse') {
|
|
415
|
-
return handlePostToolUse(input);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (eventName === 'Stop') {
|
|
419
|
-
return handleStop(input);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
return null;
|
|
423
|
-
};
|
|
1
|
+
export { handleClaudeHookEvent } from '../orchestration/adapters/claude-adapter.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { handleCursorHookEvent } from '../orchestration/adapters/cursor-adapter.js';
|